diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2c326f0d5d..5ea03947d4 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -84,6 +84,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 | | assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 | | assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 | +| assets/fonts/craft-ui-sc/CraftUI-SC.otf | Adobe / Google Inc. (Noto Sans SC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/SC Regular, glyph-subset for assets/locales/zh.json and renamed Craft UI SC (OFL reserved-name); licence text in assets/fonts/craft-ui-sc/OFL.txt; web UI Simplified Chinese | SIL OFL 1.1 | +| assets/fonts/craft-ui-tc/CraftUI-TC.otf | Adobe / Google Inc. (Noto Sans TC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/TC Regular, glyph-subset for assets/locales/zh-TW.json and renamed Craft UI TC (OFL reserved-name); licence text in assets/fonts/craft-ui-tc/OFL.txt; web UI Traditional Chinese | SIL OFL 1.1 | | tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 | | tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 | | tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 | @@ -125,6 +127,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/appearances/opaque_finishes.rs | SolidCraft contributors | original illustrative opaque finish values; no external dataset, textures or measured engineering data | MIT OR Apache-2.0 | | assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 | +| assets/fonts/craft-ui-sc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-SC.otf | SIL OFL 1.1 | +| assets/fonts/craft-ui-tc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-TC.otf | SIL OFL 1.1 | | assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 | | assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 | | assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 | @@ -611,6 +615,25 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments. | assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_horizontal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_vertical.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_angle.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_parallel.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_perpendicular.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_tangent_external.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_tangent_at.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_tangent_two.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction_line_bisector.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-circle.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-circle.json`; gitignored) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-flower.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-flower.json`; gitignored) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-involute.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-involute.json`; gitignored) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-line.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-line.json`; gitignored) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-parabola.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-parabola.json`; gitignored) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-spiral.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-spiral.json`; gitignored) | MIT OR Apache-2.0 | +| docs/book/src/generated/equation-wave.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-wave.json`; gitignored) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/print_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/locales/fi.json | SolidCraft contributors | Original Finnish translation of SolidCraft's English catalog, AI-assisted (Claude Code) clean-room from the English and not yet reviewed line by line by a native speaker; no commercial CAD text used | MIT OR Apache-2.0 | ## Code adapted from sibling projects @@ -670,6 +693,7 @@ copied into SolidCraft. | Directory | Source | Licence | Changes | |---|---|---|---| +| vendor/accesskit_macos | accesskit_macos 0.26.3, https://github.com/AccessKit/accesskit (The AccessKit contributors) | MIT OR Apache-2.0 | the subclassing adapter and its tests from 0.27.2: dropping the adapter keeps AppKit's key-value observers (vendor/README.md) | | vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) | diff --git a/Cargo.lock b/Cargo.lock index e5ef73d5fb..26a62c9c62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,8 +60,6 @@ dependencies = [ [[package]] name = "accesskit_macos" version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", "accesskit_consumer 0.38.0", @@ -4210,7 +4208,7 @@ dependencies = [ [[package]] name = "solidcraft" -version = "0.27.1" +version = "0.28.0" dependencies = [ "block2 0.6.2", "eframe", @@ -4242,15 +4240,16 @@ dependencies = [ "windows-sys 0.61.2", "winit", "x11rb", + "zbus 5.19.0", ] [[package]] name = "solidcraft-3dx-client" -version = "0.27.1" +version = "0.28.0" [[package]] name = "solidcraft-cli" -version = "0.27.1" +version = "0.28.0" dependencies = [ "image", "log", @@ -4262,7 +4261,7 @@ dependencies = [ [[package]] name = "solidcraft-derive" -version = "0.27.1" +version = "0.28.0" dependencies = [ "proc-macro2", "quote", @@ -4271,7 +4270,7 @@ dependencies = [ [[package]] name = "solidcraft-doc" -version = "0.27.1" +version = "0.28.0" dependencies = [ "log", "serde", @@ -4288,7 +4287,7 @@ dependencies = [ [[package]] name = "solidcraft-engine" -version = "0.27.1" +version = "0.28.0" dependencies = [ "image", "log", @@ -4309,7 +4308,7 @@ dependencies = [ [[package]] name = "solidcraft-geom" -version = "0.27.1" +version = "0.28.0" dependencies = [ "serde", "serde_json", @@ -4318,7 +4317,7 @@ dependencies = [ [[package]] name = "solidcraft-gpu" -version = "0.27.1" +version = "0.28.0" dependencies = [ "image", "log", @@ -4338,7 +4337,7 @@ dependencies = [ [[package]] name = "solidcraft-io" -version = "0.27.1" +version = "0.28.0" dependencies = [ "cadmpeg-core", "cadmpeg-ir", @@ -4362,7 +4361,7 @@ dependencies = [ [[package]] name = "solidcraft-kernel" -version = "0.27.1" +version = "0.28.0" dependencies = [ "log", "proptest", @@ -4382,7 +4381,7 @@ dependencies = [ [[package]] name = "solidcraft-mcp" -version = "0.27.1" +version = "0.28.0" dependencies = [ "serde_json", "solidcraft-engine", @@ -4391,7 +4390,7 @@ dependencies = [ [[package]] name = "solidcraft-render" -version = "0.27.1" +version = "0.28.0" dependencies = [ "image", "moxcms", @@ -4405,7 +4404,7 @@ dependencies = [ [[package]] name = "solidcraft-sketch" -version = "0.27.1" +version = "0.28.0" dependencies = [ "harfrust", "serde", @@ -4418,14 +4417,14 @@ dependencies = [ [[package]] name = "solidcraft-space-mouse" -version = "0.27.1" +version = "0.28.0" dependencies = [ "serde", ] [[package]] name = "solidcraft-ui-egui" -version = "0.27.1" +version = "0.28.0" dependencies = [ "arboard", "egui", @@ -4455,7 +4454,7 @@ dependencies = [ [[package]] name = "solidcraft-web" -version = "0.27.1" +version = "0.28.0" dependencies = [ "eframe", "egui", @@ -5998,7 +5997,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.27.1" +version = "0.28.0" dependencies = [ "proc-macro2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 988a5d5d67..82ea8119b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.27.1" +version = "0.28.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -165,6 +165,9 @@ egui-wgpu = { path = "vendor/egui-wgpu" } # winit 0.30 never binds and eframe 0.36 still requires winit 0.30. winit = { path = "vendor/winit" } +# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535). +accesskit_macos = { path = "vendor/accesskit_macos" } + # Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental # builds, since every mutant rebuilds the crates above the one it changes. [profile.mutants] diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index e2b1ccdb2b..3ecc7d0817 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -16799,6 +16799,14 @@ Bundled fonts (SIL Open Font License, Version 1.1) Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) Source: https://github.com/googlefonts/morisawa-biz-ud-gothic +- CraftUI-SC.otf (assets/fonts/craft-ui-sc/CraftUI-SC.otf) + Copyright 2014-2021 Adobe / Google Inc. (Noto Sans SC subset for SolidCraft web zh) + Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/SC) + +- CraftUI-TC.otf (assets/fonts/craft-ui-tc/CraftUI-TC.otf) + Copyright 2014-2021 Adobe / Google Inc. (Noto Sans TC subset for SolidCraft web zh-TW) + Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/TC) + - IBMPlexSans-Variable.ttf (assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf) Copyright 2017 IBM Corp. with Reserved Font Name "Plex" Source: https://github.com/IBM/plex diff --git a/apps/solidcraft-web/index.html b/apps/solidcraft-web/index.html index 90030947f6..e4354798a8 100644 --- a/apps/solidcraft-web/index.html +++ b/apps/solidcraft-web/index.html @@ -22,6 +22,10 @@ + + + + diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 08443ca6a2..6ebe14513d 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -273,8 +273,10 @@ mod web { } const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf"; + const CHINESE_SC_FONT_PATH: &str = "./fonts/CraftUI-SC.otf"; + const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf"; - type FontLoadResult = Rc, String>>>>; + type FontLoadResult = Rc), String>>>>; struct Shell { app: SolveApp, @@ -292,11 +294,17 @@ mod web { rename_focus: bool, /// The start page's recent designs were filled from the stored ones. recents_synced: bool, - /// On-demand CJK font: fetched only when a CJK language is selected. + /// On-demand script fonts: fetched only when a CJK language is selected. font: solidcraft_ui_egui::fonts::CjkFontLoader, font_result: FontLoadResult, - /// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks. + /// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks. cjk_bytes: Option>, + /// The fetched Chinese font's bytes (Simplified or Traditional for this session). + chinese_bytes: Option>, + /// Which Chinese face `chinese_bytes` is: `"zh"` or `"zh-TW"`. + chinese_kind: Option<&'static str>, + /// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, or `"zh-TW"`). + font_fetch_kind: Option<&'static str>, /// The language the fonts were last set up for (a CJK UI leads with the CJK face). font_language: String, space_mouse: SpaceMouse, @@ -674,32 +682,60 @@ mod web { files::autosave(&self.files, None); } - /// Fetch and install the CJK font when a CJK language is selected. If that fails, this + /// 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) { + 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(), + _ => true, + } + } + + /// Fetch and install a script font when a CJK language is selected. If that fails, this /// session falls back to English (no tofu); the saved language preference is kept. fn tick_font(&mut self, ctx: &egui::Context) { let done = self.font_result.borrow_mut().take(); if let Some(res) = done { + let kind = self.font_fetch_kind.take(); let installed = match res { - Ok(bytes) => { - self.cjk_bytes = Some(bytes); + Ok((path, bytes)) => { + match kind { + Some("zh") => { + self.chinese_bytes = Some(bytes); + self.chinese_kind = Some("zh"); + } + Some("zh-TW") => { + self.chinese_bytes = Some(bytes); + self.chinese_kind = Some("zh-TW"); + } + _ => { + self.cjk_bytes = Some(bytes); + } + } + log::info!("loaded web script font from {path}"); true } Err(err) => { - log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}"); + let path = match kind { + Some("zh") => CHINESE_SC_FONT_PATH, + Some("zh-TW") => CHINESE_TC_FONT_PATH, + _ => JAPANESE_FONT_PATH, + }; + log::warn!("failed to fetch script font from {path}: {err}"); false } }; - if installed { - log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}"); - } else if solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) { - log::warn!("CJK font unavailable; showing English for this session"); + if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) { + log::warn!("script font unavailable; showing English for this session"); } self.font.finished(installed, &mut self.app.preferences.language); self.apply_fonts(ctx); ctx.request_repaint(); } - if self.font.tick(&mut self.app.preferences.language) { - self.fetch_font(ctx); + let ready = self.script_font_ready(); + 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")); } // 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. @@ -708,31 +744,42 @@ mod web { } } - /// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback - /// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]). + /// 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) { - match self.cjk_bytes.clone() { - Some(bytes) => { - solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language); - } - None => solidcraft_ui_egui::fonts::setup_fonts(ctx), + 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, + ); } self.font_language.clone_from(&self.app.preferences.language); } - /// CJK text on screen with no CJK font yet (an English UI showing a Japanese name): - /// fetch the font, so the text does not stay tofu (#720). + /// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name): + /// fetch the Japanese font, so the text does not stay tofu (#720). fn watch_text_for_font(&mut self, ctx: &egui::Context) { if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() { - self.fetch_font(ctx); + self.fetch_font(ctx, "ja"); } } - fn fetch_font(&self, ctx: &egui::Context) { + fn fetch_font(&mut self, ctx: &egui::Context, kind: &'static str) { + let path = match kind { + "zh" => CHINESE_SC_FONT_PATH, + "zh-TW" => CHINESE_TC_FONT_PATH, + _ => JAPANESE_FONT_PATH, + }; + self.font_fetch_kind = Some(kind); let slot = self.font_result.clone(); let ctx = ctx.clone(); + let path = path.to_owned(); wasm_bindgen_futures::spawn_local(async move { - let res = fetch_font_bytes(JAPANESE_FONT_PATH).await; + let res = fetch_font_bytes(&path).await.map(|bytes| (path, bytes)); *slot.borrow_mut() = Some(res); ctx.request_repaint(); }); @@ -962,6 +1009,9 @@ mod web { font: Default::default(), font_result: Rc::new(std::cell::RefCell::new(None)), cjk_bytes: None, + chinese_bytes: None, + chinese_kind: None, + font_fetch_kind: None, font_language, space_mouse: SpaceMouse::default(), wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(), diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index a39478f403..36906ebf21 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -104,5 +104,10 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } raw-window-handle = "0.6" x11rb = { version = "0.13", default-features = false } +[target.'cfg(target_os = "linux")'.dependencies] +# The desktop's dark or light preference through the XDG Desktop Portal (src/desktop_theme.rs, +# #7944); already in the tree through eframe's accessibility support (accesskit_unix). +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } + [build-dependencies] embed-resource = "3.0.11" diff --git a/apps/solidcraft/src/control_server.rs b/apps/solidcraft/src/control_server.rs index 4f1e8b70b7..b3a197edf3 100644 --- a/apps/solidcraft/src/control_server.rs +++ b/apps/solidcraft/src/control_server.rs @@ -413,11 +413,37 @@ impl Lines { } } +/// A subscribed connection's seat on the watch: it leaves exactly once, whichever thread sees +/// the connection's end first — the connection's own thread at the end of the stream, or the +/// events thread at the first write a closed client refuses. The seat used to wait for the +/// events thread alone, and that thread only leaves its wait when the design moves, so the +/// count could outlive the connection by a second or more (#8315). +struct Seat(Mutex>); + +impl Seat { + fn new(counted: Subscriber) -> Self { + Seat(Mutex::new(Some(counted))) + } + + /// Leave the watch now: the seat goes once, whoever gets here first. + fn release(&self) { + let counted = self.0.lock().unwrap_or_else(PoisonError::into_inner).take(); + drop(counted); + } +} + +impl Drop for Seat { + fn drop(&mut self) { + self.release(); + } +} + /// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of /// their own until the connection ends. struct Subscription { lines: Arc, stop: Arc, + seat: Arc, } impl Subscription { @@ -433,15 +459,17 @@ impl Subscription { ) -> Option<(Self, std::io::Result<()>)> { let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) }); let stop = Arc::new(AtomicBool::new(false)); + let seat = Arc::new(Seat::new(counted)); let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner); - let (l, s) = (lines.clone(), stop.clone()); + let (l, s, watch_seat) = (lines.clone(), stop.clone(), Arc::clone(&seat)); // The connection's seat on the watch rides the events thread: it leaves when the thread // does, so the next failed write to a closed client retires it even while the connection's - // own thread is slow to see the end of the stream (#7226). - std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, counted)).ok()?; + // own thread is slow to see the end of the stream (#7226). The connection's own thread + // leaves it too, so a closed connection stops counting at once (#8315). + std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, watch_seat)).ok()?; let sent = send(&mut &lines.socket, reply); drop(first); - Some((Subscription { lines, stop }, sent)) + Some((Subscription { lines, stop, seat }, sent)) } } @@ -454,6 +482,9 @@ fn design_of(reply: &Value) -> Option<(String, u64)> { impl Drop for Subscription { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); + // The seat on the watch goes now: the connection is over, and nothing counts it any more + // (#8315). + self.seat.release(); // The socket closes now, not when the thread next looks; a line blocked on a client that // stopped reading ends too. let _ = self.lines.socket.shutdown(std::net::Shutdown::Both); @@ -461,10 +492,10 @@ impl Drop for Subscription { } /// The `design.changed` lines of a subscribed connection, each time the design differs from the -/// last one it was told of (`seen`), until `stop`. Owns the connection's seat on the watch -/// (`counted`), so the seat leaves exactly when this thread does: on `stop`, or on the first -/// write a closed client refuses (#7226). -fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), _counted: Subscriber) { +/// last one it was told of (`seen`), until `stop`. Holds the connection's seat on the watch +/// (`seat`), which it leaves when the thread ends: on `stop`, or on the first write a closed +/// client refuses (#7226). +fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), seat: Arc) { while !stop.load(Ordering::Relaxed) { let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue }; if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() { @@ -473,6 +504,17 @@ fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: seen = now; std::thread::sleep(EVENT_GAP); } + // The client is gone: it is not counted on the watch any more, whatever the connection's own + // thread still has to notice (#8315). + seat.release(); +} + +/// What this app's control channel does beyond the methods every version has, for a client to +/// find out before it relies on it (an older app answers `control.capabilities` with "unknown +/// method"). `param_check`: the levels `engine.execute`, `engine.batch` and `engine.script` take +/// as a request's own parameter check (#9184). +fn capabilities() -> Value { + json!({"param_check": solidcraft_engine::schema::ParamCheck::ALL.map(|l| l.name())}) } /// Write `reply` on the connection: through its subscription once it has one. @@ -535,15 +577,20 @@ fn serve_with( let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); - // A request that timed out may have finished since (#1618). `control.running` only - // looks, so it leaves that reply where it is. - let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() { + // A request that timed out may have finished since (#1618). `control.running` and + // `control.capabilities` only look, so they leave that reply where it is. + let late = pending.as_ref().filter(|_| !matches!(method.as_str(), "control.running" | "control.capabilities")).and_then(|p| match p + .reply + .try_recv() + { Ok(reply) => Some(reply), Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})), Err(TryRecvError::Empty) => None, }); let mut r = if method == "control.running" { json!({"ok": true, "result": running.result(connection)}) + } else if method == "control.capabilities" { + json!({"ok": true, "result": capabilities()}) } else if method == "control.pending" { match (pending.take(), late) { (None, _) => json!({"ok": true, "result": {"pending": null}}), @@ -750,6 +797,9 @@ mod tests { let mut watcher = open(); let (r, rows) = running(&mut watcher, &|_| true); assert!(r["ok"] == true && rows.is_empty(), "idle: {r}"); + // What the app's control channel does beyond the methods every version has (#9184). + let r = watcher(json!({"id": 0, "method": "control.capabilities", "token": token})); + assert_eq!((&r["ok"], &r["result"]["param_check"]), (&json!(true), &json!(["off", "warn", "strict"])), "{r}"); let mut export = open(); let path = "/private/part.step"; @@ -781,6 +831,9 @@ mod tests { let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect(); assert_eq!(own.len(), 1, "{r}"); assert_eq!(own[0]["command"], "file.export", "{r}"); + // So may it ask what the app can do: answered at once, not refused as busy. + let r = export(json!({"id": 8, "method": "control.capabilities"})); + assert_eq!(r["result"]["param_check"], json!(["off", "warn", "strict"]), "{r}"); gate_tx.send(()).unwrap(); assert_eq!(queued.join().unwrap()["result"], "engine.batch"); @@ -886,6 +939,58 @@ mod tests { assert!(!WATCH.subscribed()); } + /// #8315: a closed connection leaves the watch at once. The seat of a subscribed connection + /// goes when the connection's own thread sees the end of the stream, not only when the + /// events thread notices: that thread waits for the design to move, so a connection that + /// closed while the design stood still kept the watch counting it for a second or more. + #[test] + fn a_closed_connection_leaves_the_watch_at_once() { + static RUNNING: Running = Running::new(); + static WATCH: DesignWatch = DesignWatch::new(); + let token = "0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + // The window names the design once and then leaves it alone: nothing moves it, so the + // events thread has nothing to write and only the end of the connection can retire it. + std::thread::spawn(move || { + for req in rx { + if req.method == "control.subscribe" { + WATCH.publish("d-1", 3); + let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}})); + } else { + let _ = req.reply.send(json!({"ok": true, "result": req.method})); + } + } + }); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, WAITS, &RUNNING, &WATCH)); + } + }); + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut out = s.try_clone().unwrap(); + let mut lines = BufReader::new(s); + writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap(); + let mut l = String::new(); + lines.read_line(&mut l).unwrap(); + let reply: Value = serde_json::from_str(&l).unwrap(); + assert!(reply["ok"] == true, "{reply}"); + assert!(WATCH.subscribed(), "the connection is subscribed"); + // Let the events thread settle into its wait: the design stands still, so it has nothing + // to write, and only the end of the connection can retire it. + std::thread::sleep(Duration::from_millis(200)); + // The client goes away without asking anything else. + drop((out, lines)); + let deadline = Instant::now() + Duration::from_millis(400); + while WATCH.subscribed() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!WATCH.subscribed(), "a closed connection is not counted on the watch"); + } + /// A timed-out request that only reads is not said to "still apply", nor is one that only /// writes a file (an export, #1989); a change is, by name. #[test] diff --git a/apps/solidcraft/src/desktop_theme.rs b/apps/solidcraft/src/desktop_theme.rs new file mode 100644 index 0000000000..2a0ca24c41 --- /dev/null +++ b/apps/solidcraft/src/desktop_theme.rs @@ -0,0 +1,195 @@ +//! Linux: the desktop's dark or light preference, for Theme: System (#7944). +//! +//! The window toolkit does not pass it on everywhere: on GNOME (Wayland, or X11 through XWayland) +//! the window never learns it, nor when it changes, so the app kept its colours. This reads the +//! freedesktop setting through the XDG Desktop Portal's Settings interface on the session bus +//! (`org.freedesktop.appearance color-scheme`, then GNOME's `color-scheme` and `gtk-theme`), the +//! same setting other applications follow, and inside a Flatpak too. It runs on a thread of its +//! own: the first reading, then the portal's `SettingChanged` signal; where the signal can't be +//! had, a reading every few seconds. Without a portal it asks `gsettings` instead, on the same +//! thread. The window reads the last answer with an atomic load +//! (`solidcraft_ui_egui::Services::desktop_dark`), and the decision from the raw values is +//! `solidcraft_ui_egui::prefs::desktop_prefers_dark`. + +use std::io::Read; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::time::Duration; + +use zbus::zvariant::{OwnedValue, Value}; + +const UNKNOWN: u8 = 0; +const DARK: u8 = 1; +const LIGHT: u8 = 2; + +/// How often to read again where the portal's change signal is not available. +const POLL: Duration = Duration::from_secs(5); +/// How long one `gsettings` reading may take before it is given up. +const GSETTINGS_TIMEOUT: Duration = Duration::from_secs(2); + +const PORTAL: &str = "org.freedesktop.portal.Desktop"; +const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop"; +const SETTINGS: &str = "org.freedesktop.portal.Settings"; +const APPEARANCE: &str = "org.freedesktop.appearance"; +const GNOME: &str = "org.gnome.desktop.interface"; + +/// Start following the desktop's preference; the closure answers the latest reading (`None` +/// until there is one, or when the desktop says nothing). +pub fn start(ctx: &egui::Context) -> Box Option> { + let state = Arc::new(AtomicU8::new(UNKNOWN)); + let (thread_state, ctx) = (state.clone(), ctx.clone()); + if let Err(e) = std::thread::Builder::new().name("desktop-theme".into()).spawn(move || follow(&thread_state, &ctx)) { + log::warn!("desktop theme: no thread to follow it: {e}"); + } + Box::new(move || match state.load(Ordering::Relaxed) { + DARK => Some(true), + LIGHT => Some(false), + _ => None, + }) +} + +/// Keep `state` on the desktop's preference, asking for a frame whenever it changes. +fn follow(state: &AtomicU8, ctx: &egui::Context) { + let publish = |dark: Option| { + let code = match dark { + Some(true) => DARK, + Some(false) => LIGHT, + None => UNKNOWN, + }; + if state.swap(code, Ordering::Relaxed) != code { + ctx.request_repaint(); + } + }; + let portal = match portal() { + Ok(p) => Some(p), + Err(e) => { + log::info!("desktop theme: no settings portal ({e}); asking gsettings"); + None + } + }; + // The portal first; gsettings where there is none or it says nothing (its backend missing). + let read = |portal: Option<&zbus::blocking::Proxy<'static>>| portal.and_then(read_portal).or_else(read_gsettings); + let from_portal = portal.as_ref().and_then(read_portal); + let first = from_portal.or_else(read_gsettings); + publish(first); + // Wait for the portal's signal only when the portal answers: else the reading is gsettings', + // which only a new reading notices. + if let Some(p) = portal.as_ref().filter(|_| from_portal.is_some()) { + match p.receive_signal("SettingChanged") { + Ok(changes) => { + for message in changes { + if relevant(&message) { + publish(read(Some(p))); + } + } + log::info!("desktop theme: the portal's change signal ended; reading every few seconds"); + } + Err(e) => log::info!("desktop theme: no change signal ({e}); reading every few seconds"), + } + } else if portal.is_none() && first.is_none() && !gsettings_available() { + // Neither the portal nor gsettings: nothing to follow; the window toolkit's theme stays. + return; + } + loop { + std::thread::sleep(POLL); + publish(read(portal.as_ref())); + } +} + +/// The portal's Settings interface on the session bus. +fn portal() -> zbus::Result> { + let connection = zbus::blocking::Connection::session()?; + zbus::blocking::Proxy::new(&connection, PORTAL, PORTAL_PATH, SETTINGS) +} + +/// One reading through the portal. +fn read_portal(portal: &zbus::blocking::Proxy<'static>) -> Option { + let appearance = read_setting(portal, APPEARANCE, "color-scheme").and_then(|v| as_u32(&v)); + // The GNOME settings only matter when the freedesktop one has no preference. + if matches!(appearance, Some(1 | 2)) { + return solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, None, None); + } + let gnome = read_setting(portal, GNOME, "color-scheme").and_then(|v| as_string(&v)); + let gtk_theme = read_setting(portal, GNOME, "gtk-theme").and_then(|v| as_string(&v)); + solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, gnome.as_deref(), gtk_theme.as_deref()) +} + +/// A setting through `ReadOne` (portal version 2), else the older `Read`. +fn read_setting(portal: &zbus::blocking::Proxy<'static>, namespace: &str, key: &str) -> Option { + let one: zbus::Result = portal.call("ReadOne", &(namespace, key)); + one.or_else(|_| -> zbus::Result { portal.call("Read", &(namespace, key)) }).ok() +} + +/// Whether a `SettingChanged` signal is about a setting the preference is read from. +fn relevant(message: &zbus::Message) -> bool { + let body = message.body(); + let Ok((namespace, key, _)) = body.deserialize::<(String, String, OwnedValue)>() else { return false }; + matches!((namespace.as_str(), key.as_str()), (APPEARANCE, "color-scheme") | (GNOME, "color-scheme" | "gtk-theme")) +} + +/// The value inside the variants `Read` wraps it in (a portal answers one or two deep). +fn unwrap_variants<'v, 'a>(value: &'v Value<'a>) -> &'v Value<'a> { + let mut value = value; + for _ in 0..4 { + match value { + Value::Value(inner) => value = &**inner, + _ => break, + } + } + value +} + +fn as_u32(value: &Value<'_>) -> Option { + match unwrap_variants(value) { + Value::U32(n) => Some(*n), + Value::I32(n) => u32::try_from(*n).ok(), + Value::U8(n) => Some(u32::from(*n)), + _ => None, + } +} + +fn as_string(value: &Value<'_>) -> Option { + match unwrap_variants(value) { + Value::Str(s) => Some(s.as_str().to_string()), + _ => None, + } +} + +/// Without a portal: GNOME's settings through `gsettings` (`None` when it is not there). +fn read_gsettings() -> Option { + let gnome = gsettings("color-scheme"); + let gtk_theme = gsettings("gtk-theme"); + solidcraft_ui_egui::prefs::desktop_prefers_dark(None, gnome.as_deref(), gtk_theme.as_deref()) +} + +fn gsettings_available() -> bool { + gsettings("gtk-theme").is_some() +} + +/// `gsettings get org.gnome.desktop.interface `, its quoted answer, or `None` when the +/// program is missing, fails or does not answer in time. +fn gsettings(key: &str) -> Option { + let mut child = std::process::Command::new("gsettings") + .args(["get", GNOME, key]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(None) if started.elapsed() < GSETTINGS_TIMEOUT => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + let mut out = String::new(); + child.stdout.take()?.take(4096).read_to_string(&mut out).ok()?; + let value = out.trim().trim_matches('\'').to_string(); + (!value.is_empty()).then_some(value) +} diff --git a/apps/solidcraft/src/device_lost.rs b/apps/solidcraft/src/device_lost.rs index b892723c3c..2f598c69c1 100644 --- a/apps/solidcraft/src/device_lost.rs +++ b/apps/solidcraft/src/device_lost.rs @@ -5,12 +5,25 @@ //! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what //! happened ([`note`]). A device lost again soon after such a restart starts the next one on the //! software renderer, which doesn't depend on the graphics driver. +//! +//! The old process ends without tearing down its GPU objects ([`end`]): on a lost NVIDIA device +//! destroying the window's swap chain busy-waits in the driver and never returns (#9044). use crate::startup; /// Set on the start after a lost device: when the device was lost (seconds since 1970). pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST"; +/// Set on the start after a lost device: why it was lost, as wgpu said it. The old process's +/// session journal, which recorded it, is gone by then. +pub const REASON_ENV: &str = "SOLIDCRAFT_DEVICE_LOST_REASON"; + +/// The longest reason passed on ([`REASON_ENV`]) and shown, in characters. +const REASON_CHARS: usize = 300; + +/// How long the process may take to end ([`end`]) before it is aborted. +const END_GRACE: std::time::Duration = std::time::Duration::from_secs(3); + /// A loss this recent (seconds) counts for the start after it: its note, and a second loss /// within it starts on the software renderer. Inherited by later starts, an older stamp is /// ignored. @@ -32,27 +45,54 @@ pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool after_recent_loss || cpu_renderer } +/// `reason` on one line, at most [`REASON_CHARS`] long; None when there is nothing to say. +pub fn reason(reason: Option<&str>) -> Option { + let line = reason?.split_whitespace().collect::>().join(" "); + (!line.is_empty()).then(|| line.chars().take(REASON_CHARS).collect()) +} + /// What the start after a lost device tells the user, `software` when it runs on the software -/// renderer. -pub fn note(software: bool) -> String { +/// renderer, with what the driver reported ([`REASON_ENV`]). +pub fn note(software: bool, reason: Option<&str>) -> String { let mut note = String::from( "The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.", ); if software { note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver."); } + if let Some(reason) = self::reason(reason) { + note.push_str(&format!(" The graphics driver reported: {reason}")); + } note } -/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software -/// renderer when `software`. -pub fn restart(software: bool, lost_at: u64) { - let Ok(exe) = std::env::current_exe() else { return }; +/// The command that starts `exe` again with `args`, the loss stamped at `lost_at` and its +/// `reason`, on the software renderer when `software`. +fn command( + exe: std::path::PathBuf, + args: impl IntoIterator, + software: bool, + lost_at: u64, + reason: &str, +) -> std::process::Command { let mut command = std::process::Command::new(exe); - command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string()); + command.args(args).env(LOST_ENV, lost_at.to_string()); + match self::reason(Some(reason)) { + Some(reason) => command.env(REASON_ENV, reason), + // Not one inherited from an earlier loss. + None => command.env_remove(REASON_ENV), + }; if software { command.env(startup::RENDERER_ENV, "cpu"); } + command +} + +/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at` and its `reason`, +/// on the software renderer when `software`. +pub fn restart(software: bool, lost_at: u64, reason: &str) { + let Ok(exe) = std::env::current_exe() else { return }; + let mut command = command(exe, std::env::args_os().skip(1), software, lost_at, reason); #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -64,6 +104,23 @@ pub fn restart(software: bool, lost_at: u64) { } } +/// End the process now, without tearing down what is left on the lost device: the window's swap +/// chain, the viewport's buffers and pipelines. On NVIDIA's Vulkan driver destroying the swap +/// chain of a lost device busy-waits forever, which left the old window frozen at 100% of a core +/// beside the new one (#9044). Should the exit itself get stuck in a library's exit handler, the +/// process is aborted after [`END_GRACE`]. +pub fn end() -> ! { + let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| { + std::thread::sleep(END_GRACE); + eprintln!("SolidCraft graphics: still closing after the device was lost; aborting"); + std::process::abort(); + }); + if let Err(e) = watchdog { + log::warn!("graphics: no exit watchdog: {e}"); + } + std::process::exit(0) +} + #[cfg(test)] mod tests { /// Only a recent, well-formed stamp counts: a later start that inherited it says nothing. @@ -84,8 +141,34 @@ mod tests { assert!(!super::restart_on_software(false, false)); assert!(super::restart_on_software(true, false)); assert!(super::restart_on_software(false, true)); - assert!(super::note(true).contains("software rendering")); - assert!(!super::note(false).contains("software rendering")); - assert!(super::note(false).contains("kept for recovery")); + assert!(super::note(true, None).contains("software rendering")); + assert!(!super::note(false, None).contains("software rendering")); + assert!(super::note(false, None).contains("kept for recovery")); + } + + /// The start after a loss is told why (#9044): the old process's journal held the reason, and + /// it is deleted as that process closes. + #[test] + fn the_next_start_is_told_why_the_device_was_lost() { + let exe = std::path::PathBuf::from("solidcraft"); + let args = ["--control".into(), "7979".into()]; + let command = super::command(exe.clone(), args, true, 1_000, "Lost: Device lost\n (vkQueueSubmit)"); + let env = |command: &std::process::Command, name: &str| { + command.get_envs().find(|(k, _)| *k == name).map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned())) + }; + assert_eq!(env(&command, super::LOST_ENV), Some(Some("1000".into()))); + assert_eq!(env(&command, super::REASON_ENV), Some(Some("Lost: Device lost (vkQueueSubmit)".into())), "on one line"); + assert_eq!(env(&command, crate::startup::RENDERER_ENV), Some(Some("cpu".into()))); + assert_eq!(command.get_args().collect::>(), ["--control", "7979"]); + // No reason: none inherited from an earlier loss either. + let command = super::command(exe, Vec::new(), false, 1_000, " "); + assert_eq!(env(&command, super::REASON_ENV), Some(None)); + assert_eq!(env(&command, crate::startup::RENDERER_ENV), None); + + let note = super::note(false, Some("Lost: Device lost")); + assert!(note.ends_with("The graphics driver reported: Lost: Device lost"), "{note}"); + assert!(!super::note(false, Some(" \n ")).contains("reported")); + let long = "x".repeat(1_000); + assert_eq!(super::reason(Some(&long)).map(|r| r.chars().count()), Some(300)); } } diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index f2b9f4ef27..3d18f9e936 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -22,6 +22,8 @@ mod app_icon; mod come_forward; mod control_server; +#[cfg(target_os = "linux")] +mod desktop_theme; mod device_lost; #[cfg(target_os = "windows")] mod native_crash; @@ -202,13 +204,29 @@ impl eframe::App for App { } solidcraft_ui_egui::recovery_ui::close(&mut self.solve); solidcraft_ui_egui::open_elsewhere::close(&mut self.solve); - // Before a restart below starts the next instance, which would otherwise get nothing from - // the 3D mouse driver (`solidcraft_3dx_client::stop`). + // Before a restart (`Drop for App`) starts the next instance, which would otherwise get + // nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`). #[cfg(target_os = "macos")] solidcraft_3dx_client::stop(); solidcraft_engine::crash::end_session(); - if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) { - device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at); + // The next start (after a lost device, or into an update) begins once the app is dropped + // (`Drop for App`), when the settings are on disk. + } +} + +impl Drop for App { + /// eframe drops its settings storage first, which waits for `app.ron` to be written, then the + /// app, then the window and its GPU surface. So the next start begins here: started from + /// `on_exit`, it read `app.ron` while the old process was still writing it, found it + /// unreadable and moved it aside, resetting every setting (#9044). + fn drop(&mut self) { + if let Some(gpu) = &self.gpu + && let Some(lost_at) = gpu.lost_at + { + device_lost::restart(gpu.restart_on_software, lost_at, &gpu.reason); + // The window's GPU surface is still to be dropped, and on a lost device that may + // never return (#9044). + device_lost::end(); } // An update asked to restart into the new version or to run its installer. if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) { @@ -236,6 +254,8 @@ struct LostWatch { restart_on_software: bool, /// When the loss was seen: the app is closing to start again. lost_at: Option, + /// Why, as wgpu said it: passed to the next start ([`device_lost::REASON_ENV`]). + reason: String, } impl App { @@ -249,6 +269,7 @@ impl App { eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again"); solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}")); gpu.lost_at = Some(device_lost::now_secs()); + gpu.reason = reason; self.solve.quit_requested = true; } } @@ -422,6 +443,12 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services graphics_adapters: Vec::new(), graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()), window_system: window_system_now(), + // Linux: the desktop's dark or light setting, which the window is not told on every + // desktop (#7944). Windows and macOS: the window's own theme. + #[cfg(target_os = "linux")] + desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)), + #[cfg(not(target_os = "linux"))] + desktop_dark: None, http_get: Some(std::sync::Arc::new(http_get)), http_download: Some(std::sync::Arc::new(http_download)), // Already an installed app: only the browser build offers to install. @@ -750,6 +777,16 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft")); mark("main"); + // On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's + // compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer + // macOS version than the GPU's Metal compiler supports. Re-launching with + // SYSTEM_VERSION_COMPAT=1 makes the OS report the real (older) version so wgpu selects + // a standard the compiler accepts. The variable must be set before the process starts, so + // we detect OCLP here and re-exec immediately, before any macOS framework call (#3080, #4605). + #[cfg(target_os = "macos")] + if let Err(e) = startup::relaunch_with_version_compat() { + log::warn!("startup: OCLP check failed (continuing normally): {e}"); + } // Validate before legacy adoption, crash markers or any user preference read. let settings = settings_dir().map_err(|error| eframe::Error::AppCreation(error.into()))?; if std::env::var_os("SOLIDCRAFT_SETTINGS_DIR").is_none() { @@ -821,7 +858,11 @@ fn main() -> eframe::Result { // The launch before this one closed because its graphics device was lost (`App::watch_device`). let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs()); if after_loss { - notes.push(device_lost::note(cpu_renderer)); + let reason = std::env::var(device_lost::REASON_ENV).ok(); + if let Some(reason) = device_lost::reason(reason.as_deref()) { + log::warn!("graphics: the last launch lost its device ({reason})"); + } + notes.push(device_lost::note(cpu_renderer, reason.as_deref())); } if safe_mode { notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into()); @@ -961,6 +1002,7 @@ fn main() -> eframe::Result { lost: rs.device_lost.clone(), restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer), lost_at: None, + reason: String::new(), }); if !safe_mode { app.preferences.graphics_adapter = @@ -1016,6 +1058,10 @@ fn main() -> eframe::Result { app.session.doc_mut().name = "Sample Plate".into(); app.session.mark_saved(); app.session.undo.clear(); + // Built straight into the session, so nothing asked for a fit: frame it at the + // home view as any opened design is (#9195). The fit waits for the first layout. + app.cam.set_view(solidcraft_engine::render::StandardView::Iso); + app.fit_view(); } // Lock files next to open designs: a design open on another computer says so (#5467), // the files given at launch included. @@ -1099,12 +1145,13 @@ fn main() -> eframe::Result { } /// egui-wgpu is about to start a step of getting the graphics device and the window's surface up: -/// the crash journal keeps it, and the adapter once it is picked. +/// the crash journal keeps it until the app runs (a window opened later goes through the same +/// steps), and the adapter once it is picked. fn startup_step(step: &'static str, detail: &str) { if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN { solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}")); } - solidcraft_engine::crash::phase(step); + solidcraft_engine::crash::startup_step(step); } /// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The diff --git a/apps/solidcraft/src/native_menu.rs b/apps/solidcraft/src/native_menu.rs index 1d75df7a87..0e64b53871 100644 --- a/apps/solidcraft/src/native_menu.rs +++ b/apps/solidcraft/src/native_menu.rs @@ -101,6 +101,7 @@ impl NativeMenu { ])?; for (action, label) in [ ("docs", "Documentation"), + ("updates", app.tr("update.check_for_updates")), ("shortcuts", "Keyboard Shortcuts"), ("messages", "Messages\u{2026}"), ("report", "Report an Issue\u{2026}"), diff --git a/apps/solidcraft/src/startup.rs b/apps/solidcraft/src/startup.rs index a5fdbd4031..718b8388c4 100644 --- a/apps/solidcraft/src/startup.rs +++ b/apps/solidcraft/src/startup.rs @@ -292,6 +292,71 @@ pub fn window_instance_flags(flags: eframe::wgpu::InstanceFlags, intel_mac: bool if intel_mac && env.map(str::trim).is_none_or(str::is_empty) { flags - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL } else { flags } } +/// The environment variable that makes the macOS compatibility shim report an older OS +/// version to wgpu's Metal backend. On OCLP-patched machines the Metal compiler accepts +/// only up to the MSL standard of the original GPU, not the one the patched OS reports, +/// so wgpu picks a standard that the compiler refuses (`invalid value 'metal3.2'`). +/// Setting this variable before the process starts causes the OS to report the older +/// version and wgpu selects a supported MSL standard (#3080, #4605). +#[cfg(target_os = "macos")] +pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT"; + +/// The NVRAM variable that OpenCore Legacy Patcher writes on every OCLP-booted system. +/// Its presence is the primary signal that `SYSTEM_VERSION_COMPAT=1` is needed. +#[cfg(target_os = "macos")] +pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version"; + +/// `true` if the OCLP NVRAM key is present on this system. +/// +/// Runs `nvram ` and treats exit 0 as present. Any error (missing binary, +/// permission denied, key absent) returns `false` — this is a best-effort probe. +#[cfg(target_os = "macos")] +pub fn oclp_nvram_present() -> bool { + std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false) +} + +/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through +/// the NVRAM key. The probe runs only when it is needed, so a re-launched process (variable +/// set) never starts `nvram`. +#[cfg(target_os = "macos")] +fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool { + !compat_set && nvram() +} + +/// If this process is running under OCLP and `SYSTEM_VERSION_COMPAT` has not been set, +/// re-launch the current binary with `SYSTEM_VERSION_COMPAT=1` added to the environment, +/// then exit with the child's status code. +/// +/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it +/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have +/// already queried the OS version by then. Re-launching is the only safe approach that +/// works without `unsafe` (a true `execve` replacement would require it). +/// +/// Returns `Ok(false)` immediately when: +/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or +/// - the OCLP NVRAM key is absent. +/// +/// Returns `Err` only when `std::env::current_exe()` fails or the child process cannot +/// be spawned; the caller should log the error and continue normally. +/// +/// Never returns `Ok(true)`: a successful re-launch exits the current process. +#[cfg(target_os = "macos")] +pub fn relaunch_with_version_compat() -> Result { + // A set variable means this is the re-launched process: no loop. + if !compat_wanted(std::env::var_os(SYSTEM_VERSION_COMPAT_ENV).is_some(), oclp_nvram_present) { + return Ok(false); + } + log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV); + eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV); + let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?; + let status = std::process::Command::new(&exe) + .args(std::env::args_os().skip(1)) + .env(SYSTEM_VERSION_COMPAT_ENV, "1") + .status() + .map_err(|e| format!("could not re-launch with {}: {e}", SYSTEM_VERSION_COMPAT_ENV))?; + std::process::exit(status.code().unwrap_or(1)); +} + /// Windows with OpenGL: the window keeps the system title bar (#2063). The window without OS /// decorations keeps its caption style while having no frame, and some OpenGL drivers (older /// Intel HD/UHD) then show the picture a caption's height lower than the window, so it no longer @@ -414,6 +479,27 @@ mod tests { assert_eq!(window_instance_flags(on, false, None), on); } + #[cfg(target_os = "macos")] + #[test] + fn oclp_relaunch_needs_the_signal_and_no_compat_variable() { + use super::compat_wanted; + let probed = std::cell::Cell::new(false); + let probe = |found: bool| { + let probed = &probed; + move || { + probed.set(true); + found + } + }; + // The re-launched process has the variable: no probe, no second re-launch. + assert!(!compat_wanted(true, probe(true))); + assert!(!probed.get()); + // The OCLP NVRAM key is the signal. + assert!(compat_wanted(false, probe(true))); + // No signal: an ordinary Mac starts as it is. + assert!(!compat_wanted(false, probe(false))); + } + #[test] fn switches_read_from_the_environment() { assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false"))); diff --git a/assets/fonts/craft-ui-sc/CraftUI-SC.otf b/assets/fonts/craft-ui-sc/CraftUI-SC.otf new file mode 100644 index 0000000000..4763938afe Binary files /dev/null and b/assets/fonts/craft-ui-sc/CraftUI-SC.otf differ diff --git a/assets/fonts/craft-ui-sc/OFL.txt b/assets/fonts/craft-ui-sc/OFL.txt new file mode 100644 index 0000000000..d71df9fdcd --- /dev/null +++ b/assets/fonts/craft-ui-sc/OFL.txt @@ -0,0 +1,96 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. +Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'. +Subset for SolidCraft web UI (glyphs used by the zh locale); renamed Craft UI SC (no Reserved Font Name). + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/craft-ui-tc/CraftUI-TC.otf b/assets/fonts/craft-ui-tc/CraftUI-TC.otf new file mode 100644 index 0000000000..3dae5a0201 Binary files /dev/null and b/assets/fonts/craft-ui-tc/CraftUI-TC.otf differ diff --git a/assets/fonts/craft-ui-tc/OFL.txt b/assets/fonts/craft-ui-tc/OFL.txt new file mode 100644 index 0000000000..5041faaf5e --- /dev/null +++ b/assets/fonts/craft-ui-tc/OFL.txt @@ -0,0 +1,96 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. +Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'. +Subset for SolidCraft web UI (glyphs used by the zh-TW locale); renamed Craft UI TC (no Reserved Font Name). + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/icons/ui/design/palette.toml b/assets/icons/ui/design/palette.toml index c23f252be4..770c9b4457 100644 --- a/assets/icons/ui/design/palette.toml +++ b/assets/icons/ui/design/palette.toml @@ -204,7 +204,10 @@ constraint = [ "c_polygon", "c_smooth", "c_symmetry", "c_tangent", ] construction = [ - "centerline", "construction", "infinite", + "centerline", "construction", "construction_line", "construction_line_angle", + "construction_line_bisector", "construction_line_horizontal", "construction_line_parallel", + "construction_line_perpendicular", "construction_line_tangent_at", "construction_line_tangent_external", + "construction_line_tangent_two", "construction_line_vertical", "infinite", ] mesh = [ "mesh_body", diff --git a/assets/icons/ui/design/requested_icons.txt b/assets/icons/ui/design/requested_icons.txt index 038e79783f..e102eb58e2 100644 --- a/assets/icons/ui/design/requested_icons.txt +++ b/assets/icons/ui/design/requested_icons.txt @@ -70,6 +70,16 @@ configuration_table false conic false constrainer false construction false +construction_line false +construction_line_angle false +construction_line_bisector false +construction_line_horizontal false +construction_line_parallel false +construction_line_perpendicular false +construction_line_tangent_at false +construction_line_tangent_external false +construction_line_tangent_two false +construction_line_vertical false contact_all false contact_new false contact_off false @@ -191,6 +201,7 @@ polygon false polygon_circumscribed false polygon_edge false presspull false +print_face false project false project_surface false rack_pinion false diff --git a/assets/icons/ui/design/svg/model/construction_line.svg b/assets/icons/ui/design/svg/model/construction_line.svg new file mode 100644 index 0000000000..6f511bed97 --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line.svg @@ -0,0 +1,8 @@ + + Construction line through two points + + + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_angle.svg b/assets/icons/ui/design/svg/model/construction_line_angle.svg new file mode 100644 index 0000000000..4492432138 --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_angle.svg @@ -0,0 +1,8 @@ + + Construction line at an angle + + + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_bisector.svg b/assets/icons/ui/design/svg/model/construction_line_bisector.svg new file mode 100644 index 0000000000..a6d1198088 --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_bisector.svg @@ -0,0 +1,6 @@ + + Angle bisector + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_horizontal.svg b/assets/icons/ui/design/svg/model/construction_line_horizontal.svg new file mode 100644 index 0000000000..9db9affb38 --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_horizontal.svg @@ -0,0 +1,6 @@ + + Horizontal construction line + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_parallel.svg b/assets/icons/ui/design/svg/model/construction_line_parallel.svg new file mode 100644 index 0000000000..aa8ff96d0d --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_parallel.svg @@ -0,0 +1,5 @@ + + Parallel construction line + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_perpendicular.svg b/assets/icons/ui/design/svg/model/construction_line_perpendicular.svg new file mode 100644 index 0000000000..93f6d4b93f --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_perpendicular.svg @@ -0,0 +1,6 @@ + + Perpendicular construction line + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_tangent_at.svg b/assets/icons/ui/design/svg/model/construction_line_tangent_at.svg new file mode 100644 index 0000000000..b8ee5abc93 --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_tangent_at.svg @@ -0,0 +1,7 @@ + + Tangent at a curve point + + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_tangent_external.svg b/assets/icons/ui/design/svg/model/construction_line_tangent_external.svg new file mode 100644 index 0000000000..7c584cef89 --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_tangent_external.svg @@ -0,0 +1,7 @@ + + Tangent through an external point + + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_tangent_two.svg b/assets/icons/ui/design/svg/model/construction_line_tangent_two.svg new file mode 100644 index 0000000000..cd6b84547f --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_tangent_two.svg @@ -0,0 +1,6 @@ + + Common tangent to two curves + + + + diff --git a/assets/icons/ui/design/svg/model/construction_line_vertical.svg b/assets/icons/ui/design/svg/model/construction_line_vertical.svg new file mode 100644 index 0000000000..67001285ee --- /dev/null +++ b/assets/icons/ui/design/svg/model/construction_line_vertical.svg @@ -0,0 +1,6 @@ + + Vertical construction line + + + + diff --git a/assets/icons/ui/design/svg/model/print_face.svg b/assets/icons/ui/design/svg/model/print_face.svg new file mode 100644 index 0000000000..107dc12568 --- /dev/null +++ b/assets/icons/ui/design/svg/model/print_face.svg @@ -0,0 +1,19 @@ + + Print Face + + + + + + + + + + + + + + + + + diff --git a/assets/locales/ar.json b/assets/locales/ar.json index e4da54023b..696427396d 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.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": "تخصيص", @@ -775,6 +778,8 @@ "at the face": "عند الوجه", "bom.group_children": "الأجزاء الفرعية في صف الجزء الأصلي", "browser.body_name": "جسم {number}", + "browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.", + "browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.", "browser.calibrate_title": "معايرة: {name}", "browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.", "browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.", @@ -817,6 +822,7 @@ "cmd.appearance_pick_up": "التقاط المظهر", "cmd.appearance_replace": "استبدال المظهر", "cmd.assembly_bom": "قائمة المواد", + "cmd.body.print_face": "وجه الطباعة", "cmd.body_rename": "إعادة تسمية الجسم", "cmd.browser_collapse_all": "طي الكل", "cmd.browser_expand_all": "توسيع الكل", @@ -1101,6 +1107,16 @@ "cmd.sheet_refold": "إعادة الطي", "cmd.sheet_rules": "قائمة قواعد الصفائح المعدنية", "cmd.sheet_unfold": "فرد", + "cmd.sketch.construction_line": "خط إنشائي", + "cmd.sketch.construction_line.angle": "خط إنشائي بزاوية", + "cmd.sketch.construction_line.bisector": "منصف الزاوية", + "cmd.sketch.construction_line.horizontal": "خط إنشائي أفقي", + "cmd.sketch.construction_line.parallel": "خط إنشائي موازٍ", + "cmd.sketch.construction_line.perpendicular": "خط إنشائي عمودي", + "cmd.sketch.construction_line.tangent_at": "مماس عند نقطة على المنحنى", + "cmd.sketch.construction_line.tangent_external": "مماس يمر بنقطة خارجية", + "cmd.sketch.construction_line.tangent_two": "مماس مشترك", + "cmd.sketch.construction_line.vertical": "خط إنشائي رأسي", "cmd.sketch_arc3d": "قوس ثلاثي الأبعاد", "cmd.sketch_arc_center_point": "قوس بنقطة مركز", "cmd.sketch_arc_tangent": "قوس مماسي", @@ -1389,7 +1405,18 @@ "help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.", "help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.", "help.pointcloud.insert": "أدرج مسحًا بصيغة PLY أو XYZ أو PTS كسحابة نقاط بجانب النموذج. استخدمه كمرجع عند النمذجة انطلاقًا من بيانات القياس.", + "help.sketch.construction_line": "أنشئ خطًا إنشائيًا لا نهائيًا يمر بنقطتين. استخدمه لتخطيط الرسم دون تقسيم مقاطعه.", + "help.sketch.construction_line.angle": "أنشئ خطًا إنشائيًا لا نهائيًا بزاوية من محور X للرسم. اختر النقطة ثم اكتب الزاوية، أو اختر اتجاهه.", + "help.sketch.construction_line.bisector": "اختر خطين مستقيمين متقاطعين في الرسم، ثم اختر أحد منصفي الزاوية. يتبع الناتج الخطين اللذين أُنشئ منهما.", + "help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.", + "help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له. انقر بزر الفأرة الأيمن لتغيير المرجع.", + "help.sketch.construction_line.perpendicular": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا عموديًا عليه. انقر بزر الفأرة الأيمن لتغيير المرجع.", + "help.sketch.construction_line.tangent_at": "أنشئ مماسًا عند نقطة على دائرة أو قوس دائري، أو عند نهاية منحنى شريحي مفتوح أو قطع مخروطي مفتوح. اختر المنحنى ثم نقطة التماس.", + "help.sketch.construction_line.tangent_external": "اختر دائرة أو قوسًا دائريًا، ثم نقطة خارجية وحل المماس. تعرض المعاينة نقاط التماس الواقعة على القوس المرسوم فقط.", + "help.sketch.construction_line.tangent_two": "اختر دائرتين أو قوسين دائريين ثم اختر مماسًا مشتركًا من المعاينة. يجب أن تقع نقطتا التماس على المنحنيين المرسومين.", + "help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.", "help.sketch.equation_curve": "يرسم منحنى من صيغ على مدى: x(t) و y(t)، أو y = f(x). يمكن أن تستخدم الصيغ المعلمات فيتبعها المنحنى: استخدمه لموجة جيبية أو لولب أو منحنى انفلات لسن ترس.", + "help.sketch.inspect": "يفتح تقريرًا عن الرسم المفتوح: عدد درجات الحرية المتبقية وهل هو مقيد بالكامل. استخدمه لمعرفة ما لا يزال يتحرك قبل إنهاء الرسم.", "help.solid.align": "يحرّك الأجسام المحددة بحيث تلتقي نقطة أو وجه أو مستوى منها بنقطة أو وجه أو مستوى في مكان آخر. استخدمه لوضع قطعة ملاصقة لأخرى دون قياس المسافة.", "help.solid.chamfer": "يقطع الحواف المحددة بشطفة مستوية بالمسافة التي تحددها. استخدمه لكسر الحواف الحادة أو لتسهيل إدخال مسمار أو برغي.", "help.solid.combine": "يضم الأجسام، أو يقتطعها من جسم هدف، أو يُبقي الحجم المشترك بينها فقط، ويمكنه الاحتفاظ بأجسام الأداة. استخدمه لدمج القطع، أو لقطع شكل بشكل آخر، أو لمعرفة موضع تداخل جسمين.", @@ -1592,6 +1619,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} ث", @@ -1744,6 +1775,11 @@ "not a move": "ليس تحريكًا", "not a number": "ليس عددًا", "open a sketch first": "افتح رسمًا أولًا", + "parameters.apply_live": "طبّق كل تغيير عند مغادرة حقله", + "parameters.apply_live_tip": "عند الإيقاف: تنتظر التغييرات حتى «تطبيق» أو «موافق»، ثم يُعاد بناء التصميم مرة واحدة لها جميعًا. «إلغاء» أو Escape يتجاهلها.", + "parameters.held": "{count} تغييرات بانتظار «تطبيق»", + "parameters.held_hint": "عدّل التعابير والوحدات. تنتظر التغييرات «تطبيق» أو «موافق».", + "parameters.held_one": "تغيير واحد بانتظار «تطبيق»", "parts.automatic_name": "تلقائي", "parts.dimension.across_flats": "مقاس المفتاح", "parts.dimension.bore": "قطر الثقب", @@ -1766,6 +1802,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", @@ -1818,6 +1856,8 @@ "prefs.bottom_centre": "أسفل الوسط", "prefs.bottom_right": "أسفل اليمين", "prefs.browser": "المستعرض", + "prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي", + "prefs.browser_eye_left_note": "تعرض صفوف المستعرض أيقونة الإظهار بجانب سهم الطي كما كانت من قبل، بدلاً من عمود في الطرف الأيمن للصفوف.", "prefs.browser_reveal_on_hover": "إظهار عند مرور المؤشر", "prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند مرور المؤشر» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.", "prefs.browser_shown": "ظاهر", @@ -1868,6 +1908,8 @@ "prefs.graphics_card_restart": "يُطبَّق بعد إعادة تشغيل SolidCraft. يتجاوز WGPU_ADAPTER_NAME هذا الاختيار.", "prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.", "prefs.graphics_presets_note": "الإعدادات المسبقة تضبط الخيارات أدناه معًا.", + "prefs.hide_constraint_glyphs_zoomed_out": "إخفاء رموز القيود عند التصغير", + "prefs.hide_constraint_glyphs_zoomed_out_note": "تختفي رموز القيود عندما تُصغَّر المسودة بما يكفي حتى لا تزحم العرض. معطّل: تبقى بحجم شاشة ثابت عند أي تكبير.", "prefs.history": "السجل", "prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى", "prefs.history_origin_bodies_note": "يبدأ السجل على اليسار أو تحت المستعرض بمجلد الأجسام للمكوّن النشط وبالأصل، مع أيقونات الإظهار والقوائم كما في المستعرض. لا يتغير السجل في الأسفل.", @@ -1906,6 +1948,9 @@ "prefs.max_file_size_step": "ملفات STEP والاستيراد", "prefs.max_file_size_web": "يفتح تطبيق الويب ملفات STEP والاستيراد حتى 512 MB والتصاميم حتى 256 MB؛ لا يتسع تبويب المتصفح لملفات أكبر. يمكن رفع هذه الحدود في تطبيق سطح المكتب.", "prefs.megabytes": "MB", + "prefs.menu_grid": "أيقونات كبيرة", + "prefs.menu_list": "قائمة", + "prefs.menus": "القوائم", "prefs.minutes": "دقائق", "prefs.minutes_suffix": " دقيقة", "prefs.mouse": "الفأرة ولوحة اللمس", @@ -1925,10 +1970,16 @@ "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": "مجال الرؤية", "prefs.placement": "الموضع", "prefs.prefer_free_sketch_points": "تفضيل نقاط الرسم القابلة للتحريك عند تداخل النقاط", "prefs.preview_choices": "معاينة اختياراتك", + "prefs.print_face_highlight": "إبراز وجه الطباعة", + "prefs.print_face_highlight_tip": "مدى قوة تلوين وجه الطباعة للجسم وتحديده في العرض. ‏0 % لا يُظهر أي إبراز.", "prefs.profile_active": "ملف التعريف النشط", "prefs.profile_applied": "تم تطبيق ملف التعريف.", "prefs.profile_changed": "تغيّرت الإعدادات منذ حفظ ملف التعريف. «حفظ» يحفظها فيه.", @@ -1950,6 +2001,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} محفوظة", @@ -1963,6 +2016,8 @@ "prefs.search_empty": "لا توجد إعدادات مطابقة", "prefs.select": "تحديد", "prefs.select_one_item": "تحديد عنصر واحد", + "prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»", + "prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.", "prefs.selecting": "التحديد", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل", @@ -1974,13 +2029,16 @@ "prefs.sketch_align_points_note": "أثناء الرسم، تتحاذى النقطة أفقيًا مع نقطة رسم وعموديًا مع نقطة أخرى في الوقت نفسه، مع خط إرشادي منقط، وتحتفظ النقرة بالمحاذاتين. عند الإيقاف: مع النقطة السابقة فقط، محور واحد في كل مرة.", "prefs.sketch_conflict_colour": "الهندسة المتعارضة بلون التعارض", "prefs.sketch_conflict_colour_note": "عندما تتعارض قيود الرسم، تُرسم المنحنيات والنقاط التي يمسكها قيد فاشل بلون التعارض أيضًا، لا رمزه أو بُعده فقط. عند الإيقاف: تحتفظ بألوانها المعتادة.", + "prefs.sketch_construction_lines": "أدوات الخطوط الإنشائية الموسعة", "prefs.sketch_exit_view": "عند انتهاء الرسم", "prefs.sketch_exit_view.keep": "إبقاء العرض الحالي", "prefs.sketch_exit_view.previous": "العودة إلى العرض السابق", "prefs.sketch_exit_view.unless_rotated": "العودة ما لم يُدوَّر العرض", "prefs.sketch_exit_view_note": "أين ينتقل العرض بعد إنهاء الرسم أو الإلغاء أو الضغط على Esc مرتين.", + "prefs.sketch_open_look_at": "توجيه العرض نحو الرسم عند فتحه", + "prefs.sketch_open_look_at_note": "عند الإيقاف: إنشاء الرسم أو تحريره يُبقي العرض كما هو. يوجّه «النظر إلى» العرض نحو الرسم عند الحاجة.", "prefs.sketch_recent_snap": "الالتقاط التلقائي إلى المراكز ونقاط المنتصف الأخيرة", - "prefs.sketch_recent_snap_note": "أثناء الرسم، يلتقط المؤشر الموضع الذي يتقاطع فيه دليلا آخر مركزي دائرتين أو نقطتي منتصف حافتين مستقيمتين. إيقاف: تبقى الأدلة للعرض فقط ولا يتحرك المؤشر.", + "prefs.sketch_recent_snap_note": "أثناء الرسم، يلتقط المؤشر الموضع الذي يتقاطع فيه دليلا آخر مركزي دائرتين أو نقطتي منتصف حافتين مستقيمتين. عند الإيقاف: تبقى تلك الأدلة للعرض فقط ولا يتحرك المؤشر.", "prefs.sketch_selection": "الرسم والتحديد", "prefs.sketch_selection_heading": "الرسم والتحديد", "prefs.sketch_selection_note": "كيف تلتقط الرسومات وكيف تحدد النقرات.", @@ -2038,13 +2096,21 @@ "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": "أثناء فتح مربع حوار أو رسم، تؤدي علامة الصح وعلامة الإكس أعلى يمين العرض ما يؤديه زرّا موافق وإلغاء في مربع الحوار، أو تنهي الرسم وتلغيه. وينتقل متصفح العرض إلى أسفلهما.", "prefs.viewport": "منطقة العرض", + "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.wheel": "العجلة", "prefs.wheel_trackpad": "العجلة ولوحة اللمس", "prefs.workspace": "مساحة العمل", @@ -2054,6 +2120,10 @@ "properties.title": "الخصائص: {name}", "recovery.days_ago": "قبل {count} يوم", "recovery.detail": "{file} · {features} ميزة · حُفظ تلقائيًا {when}", + "recovery.discard_all": "تجاهل الكل", + "recovery.discard_all_detail": "لا يمكن التراجع عن هذا الإجراء.", + "recovery.discard_all_title": "تجاهل {count} من عمليات الحفظ التلقائي؟", + "recovery.discard_all_title_one": "تجاهل حفظ تلقائي واحد؟", "recovery.hours_ago": "قبل {count} ساعة", "recovery.just_now": "الآن", "recovery.min_ago": "قبل {count} دقيقة", @@ -2919,7 +2989,9 @@ "render.output_ready": "جاهز للتصيير", "render.output_run_check": "تشغيل الفحص", "render.output_size_limit": "{method}: حتى {dimension} بكسل لكل ضلع و{pixels} بكسل. أكبر حجم مناسب: {width} × {height}.", + "render.output_size_method_profile": "لتصيير {width} × {height}، اختر {method} مع ملف موارد CPU «{profile}»، أو حجمًا أصغر.", "render.output_size_ok": "الحجم مناسب · {width} × {height} بكسل", + "render.output_size_profile": "لتصيير {width} × {height}، اختر ملف موارد CPU «{profile}»، أو حجمًا أصغر.", "render.output_swap_orientation": "تبديل العرض والارتفاع", "render.output_tools_inspector": "عدّل الحجم والصيغة في مفتش المخرجات.", "render.output_view_image": "عرض الصورة", @@ -3132,6 +3204,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": "معاينة", @@ -3419,6 +3492,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 معرّف أمر", "shortcut.pin": "تثبيت في أعلى مربع S", @@ -3455,6 +3546,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": "النص يتبع مساره: المنحنى يحدد موضعه", @@ -3595,6 +3687,8 @@ "ui.3d_mouse_section": "الفأرة ثلاثية الأبعاد", "ui.3d_mouse_show_in_view": "إظهار الفأرة ثلاثية الأبعاد في العرض ثلاثي الأبعاد", "ui.3d_mouse_show_in_view_tip": "صورة للفأرة ثلاثية الأبعاد في الزاوية السفلية اليسرى، تتحرك كلما حرّكتها.", + "ui.3d_mouse_sketch_orbit": "تدوير الرسومات بالفأرة ثلاثية الأبعاد", + "ui.3d_mouse_sketch_orbit_tip": "تدير الفأرة ثلاثية الأبعاد العرض أيضًا أثناء فتح رسم ورؤيته من الأمام مباشرة، كما تفعل الفأرة. عند الإيقاف: تُزيح الرسم وتكبّره فقط حتى تُميل العرض.", "ui.3d_mouse_skip": "جهازي لا يستطيع ذلك", "ui.3d_mouse_skip_tip": "يتخطى هذه الحركة في الاتجاهين، وتستبعدها المعايرة.", "ui.3d_mouse_speed": "سرعة الفأرة ثلاثية الأبعاد", @@ -3623,6 +3717,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "لُفّ الغطاء عكس عقارب الساعة، وأمسكه، ثم اتركه.", "ui.3d_mouse_switch_cap": "المعايرة كجهاز العودة إلى المركز", "ui.3d_mouse_switch_trackball": "المعايرة ككرة تتبّع", + "ui.3d_mouse_use_direct": "المشغل المباشر", + "ui.3d_mouse_use_direct_tip": "قراءة الفأرة ثلاثية الأبعاد مباشرة (hidraw) بدلًا من المرور عبر spacenavd.", "ui.3d_mouse_view_axis": "محور العرض", "ui.3d_mouse_view_off": "متوقف", "ui.3d_mouse_view_off_tip": "ملف تعريف مشغّل الفأرة ثلاثية الأبعاد يوقف هذا المحور، فلا تحرك حركتُه العرض. اختيار حركة محور آخر هنا يبادل بينهما، فتتوقف تلك الحركة عن تحريك العرض.", @@ -3756,6 +3852,8 @@ "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": "حفظ", @@ -3939,6 +4037,7 @@ "ui.circle": "دائرة", "ui.circular_pattern": "نمط دائري", "ui.clear": "مسح", + "ui.clear_print_face": "مسح وجه الطباعة", "ui.clear_recent": "مسح الأخيرة", "ui.clear_the_selection": "مسح التحديد", "ui.click_selects_whole_bodies": "النقر يحدد الأجسام كاملة", @@ -3973,6 +4072,15 @@ "ui.constant": "ثابت", "ui.constraints_conflict": "تعارض في القيود", "ui.construction": "مساعد", + "ui.construction.angle_hint": "خط إنشائي: انقر نقطة ثم أدخل زاوية أو اختر اتجاهًا", + "ui.construction.bisector_hint": "منصف زاوية: اختر خطين مستقيمين متقاطعين ثم الحل المطلوب", + "ui.construction.common_hint": "مماس مشترك: اختر دائرتين/قوسين ثم الحل المطلوب", + "ui.construction.contact_hint": "مماس: اختر دائرة/قوسًا أو منحنى شريحيًا/قطعًا مخروطيًا مفتوحًا ثم نقطة التماس (للمنحنيات الحرة: النهايات فقط)", + "ui.construction.external_hint": "مماس: اختر دائرة أو قوسًا ثم نقطة خارجية ثم الحل المطلوب", + "ui.construction.free_hint": "خط إنشائي: انقر نقطتين؛ انقر بزر الفأرة الأيمن للبدء من جديد", + "ui.construction.point_hint": "خط إنشائي: انقر نقطة المرور", + "ui.construction.reference_hint": "خط إنشائي: اختر خطًا مستقيمًا ثم نقطة؛ انقر بزر الفأرة الأيمن لتغيير المرجع", + "ui.construction.ribbon_label": "خطوط مساعدة", "ui.context_menu": "القائمة السياقية", "ui.continuity_g0": "G0 تجعّد", "ui.continuity_g1": "G1 مماس", @@ -4362,12 +4470,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": "وحدات الملف", @@ -4376,6 +4489,11 @@ "ui.polygon": "مضلع", "ui.preferences": "التفضيلات", "ui.press_a_key": "اضغط مفتاحًا…", + "ui.print_face_cleared": "{body}: تم مسح وجه الطباعة", + "ui.print_face_input": "وجه الطباعة", + "ui.print_face_one_body": "يُختار وجه الطباعة لجسم واحد: أرسل ذلك الجسم وحده لتغييره", + "ui.print_face_pick": "اختر وجهًا مستويًا واحدًا لجسم صلب لجعله وجه الطباعة", + "ui.print_face_set": "{body}: تم تعيين وجه الطباعة؛ الإرسال إلى برنامج التقطيع يضع الجسم عليه", "ui.profile": "المقطع", "ui.profile_plane": "مستوى المقطع", "ui.profiles": "المقاطع", @@ -4439,6 +4557,7 @@ "ui.revolve": "دوران", "ui.ribbon_exit": "خروج", "ui.ribbon_labels": "إظهار النص تحت أيقونات الشريط", + "ui.ribbon_menus_skip_pinned": "استبعاد الأوامر المثبتة من القوائم المنسدلة", "ui.ribbon_pin_footer": "الأوامر المثبتة تظهر في الشريط. مرّر المؤشر على صف لتثبيته أو إلغاء تثبيته.", "ui.ribbon_workspace": "مساحة العمل", "ui.right": "يمين", @@ -4457,6 +4576,7 @@ "ui.selectOther": "تحديد آخر…", "ui.selectTangentChain": "تحديد سلسلة مماسية", "ui.select_all": "تحديد الكل", + "ui.select_components_by_face": "تحديد المكوّنات (تحديد وجه يحدّد المكوّن الذي ينتمي إليه)", "ui.selection": "التحديد", "ui.selection_modifiers": "مُعدِّلات التحديد", "ui.selection_shift_add_only": "Shift مع النقر يضيف دون إزالة", @@ -4498,6 +4618,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": "تقطيع", @@ -4602,10 +4727,19 @@ "ui.view_centre": "مركز العرض", "ui.viewport_status_clear": "إيقاف {name}", "ui.viewport_status_clear_hint": "إيقاف {name} · Esc", + "ui.viewport_status_edges": "{count} حواف", + "ui.viewport_status_from": "من {direction}", "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": "مظلل مع الحواف", @@ -4642,7 +4776,9 @@ "update.button": "تحديث", "update.button_version": "التحديث إلى {version}", "update.check_automatically": "التحقق من التحديثات تلقائيًا", + "update.check_for_updates": "التحقق من وجود تحديثات", "update.check_now": "تحقق الآن", + "update.check_unavailable": "التحقق من التحديثات غير متاح لهذا التثبيت.", "update.checking": "جارٍ التحقق…", "update.copy": "نسخ", "update.dmg_opened": "صورة القرص مفتوحة وموثّقة: اسحب SolidCraft إلى مجلد التطبيقات مستبدلًا النسخة القديمة، ثم أعد تشغيل SolidCraft.", diff --git a/assets/locales/bs.json b/assets/locales/bs.json index 1b0720a035..38c0420bea 100644 --- a/assets/locales/bs.json +++ b/assets/locales/bs.json @@ -206,6 +206,7 @@ "Edges": "Ivice", "Edit": "Uredi", "Edit Profile Sketch": "Uredi skicu profila", + "Edit shortcuts": "Uredi prečice", "Enable rule": "Omogući pravilo", "Enabled": "Omogućeno", "End Radius": "Krajnji poluprečnik", @@ -468,6 +469,7 @@ "Radius": "Poluprečnik", "Radius Fit": "Uklapanje poluprečnika", "Ratio": "Omjer", + "Read-only list: shortcuts can't be changed here.": "Popis samo za čitanje: prečice se ovdje ne mogu mijenjati.", "Reattach selected face": "Ponovo pričvrsti izabrano lice", "Recover": "Oporavi", "Recover unsaved design?": "Oporaviti nesačuvani dizajn?", @@ -652,6 +654,7 @@ "Third point": "Treća tačka", "This child is shared. Deleting it removes it from every copy of its parent.": "Ovaj podređeni element je dijeljen. Brisanjem se uklanja iz svake kopije njegovog roditelja.", "This child is shared. Position changes apply to every copy of its parent.": "Ovaj podređeni element je dijeljen. Promjene položaja primjenjuju se na svaku kopiju njegovog roditelja.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Ova komponenta je unutar podsklopa koji je postavljen više puta: njeno poravnavanje pomjera je u svakom postavljanju.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ovo lice ne može dati trajno pričvršćenje. Izaberite drugo lice ili izričito koristite fiksnu ravan.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ova slika još nije priložena na GitHubu. Kopirajte je ili sačuvajte, a zatim je priložite na GitHubu.", "Thread depth": "Dubina navoja", @@ -772,6 +775,8 @@ "at the face": "na licu", "bom.group_children": "Poddijelovi u redu svog dijela", "browser.body_name": "Tijelo{number}", + "browser.calibrate_click_a": "Kliknite sliku da zadate tačku A ili upišite njene koordinate.", + "browser.calibrate_click_b": "Kliknite sliku da zadate tačku B ili upišite njene koordinate.", "browser.calibrate_title": "Kalibriši: {name}", "browser.capture_many": "Pomjerene komponente bez zabilježenog položaja: {count}.", "browser.capture_note": "Zabilježi zadržava novi položaj; Vrati ga vraća nazad.", @@ -793,6 +798,11 @@ "cam.roller": "Valjak", "cam.roller_pick": "Valjak (neobavezno)", "cam.tip_pick": "Vrh (neobavezno)", + "camera.shot_pitch": "Nagib", + "camera.shot_roll": "Valjanje", + "camera.shot_yaw": "Skretanje", + "canvas.decal_place_hint": "Kliknite lice da postavite naljepnicu", + "canvas.place_hint": "Kliknite ravan ili lice da postavite platno", "choose a profile": "izaberite profil", "choose the cam's and the follower's joints": "izaberite spojeve brijega i pratioca", "choose the joints to couple": "izaberite spojeve koje treba spregnuti", @@ -800,12 +810,16 @@ "click sketch curves": "kliknite na krive skice", "cmd.appearance_assign": "Izgled", "cmd.appearance_chain": "Lanac izgleda", + "cmd.appearance_copy": "Kopiraj izgled", + "cmd.appearance_favorite": "Omiljeni izgled", "cmd.appearance_library": "Biblioteka izgleda", "cmd.appearance_list": "Popis izgleda", "cmd.appearance_paint": "Oboji izgledom", + "cmd.appearance_paste": "Zalijepi izgled", "cmd.appearance_pick_up": "Preuzmi izgled", "cmd.appearance_replace": "Zamijeni izgled", "cmd.assembly_bom": "Sastavnica", + "cmd.body.print_face": "Površina za štampu", "cmd.body_rename": "Preimenuj tijelo", "cmd.browser_collapse_all": "Skupi sve", "cmd.browser_expand_all": "Proširi sve", @@ -813,6 +827,7 @@ "cmd.browser_move": "Premjesti stavke stabla modela", "cmd.browser_order": "Poredaj stavke stabla modela", "cmd.browser_rename_group": "Preimenuj grupu", + "cmd.browser_reparent": "Premjesti u komponentu", "cmd.browser_ungroup": "Razgrupiši", "cmd.browser_visibility": "Prikaži/sakrij", "cmd.canvas_calibrate": "Kalibriši platno", @@ -839,7 +854,9 @@ "cmd.component_metadata": "Svojstva dijela", "cmd.component_metadata_set": "Promijeni svojstva dijela", "cmd.component_move_bodies": "Premjesti tijela u komponentu", + "cmd.component_move_features": "Premjesti operacije u komponentu", "cmd.component_move_sketches": "Premjesti skice u komponentu", + "cmd.component_paste_from_design": "Zalijepi iz drugog dizajna", "cmd.component_paste_new": "Zalijepi kao novo", "cmd.component_properties": "Fizička svojstva komponente", "cmd.component_relink": "Ponovo poveži izvor", @@ -976,6 +993,7 @@ "cmd.motion_trace": "Trag kretanja", "cmd.motion_trace_edit": "Uredi trag kretanja", "cmd.motion_trace_keep": "Zadrži trag kretanja", + "cmd.occurrence_align": "Poravnaj primjerak", "cmd.occurrence_copy": "Zalijepi (primjerak)", "cmd.occurrence_drag": "Povuci primjerak", "cmd.occurrence_ground": "Učvrsti", @@ -1033,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Ukloni vezu površine", "cmd.render_library_targets": "Pronađi ciljeve biblioteke renderovanja", "cmd.render_lights_add": "Dodaj svjetlo", + "cmd.render_lights_duplicate": "Dupliciraj svjetlo renderovanja", + "cmd.render_lights_list": "Popis svjetala renderovanja", + "cmd.render_lights_mute": "Isključi svjetlo", + "cmd.render_lights_remove": "Ukloni svjetlo renderovanja", + "cmd.render_lights_set": "Postavi svjetlo renderovanja", "cmd.render_lights_solo": "Samo ovo svjetlo", "cmd.render_optics_bind": "Sačuvaj optičku vezu", "cmd.render_optics_unbind": "Ukloni optičku vezu", "cmd.render_output_size": "Postavi veličinu izlaza renderovanja", "cmd.render_preflight": "Provjeri zahtjev za renderovanje", + "cmd.render_preflight_copy": "Kopiraj izvještaj provjere renderovanja", "cmd.render_preset_apply": "Primijeni izvorni studio", "cmd.render_preset_resolve": "Pregledaj izvorni studio", "cmd.render_quality_prepare": "Pregledaj fiksni budžet uzorkovanja", @@ -1060,6 +1084,13 @@ "cmd.render_start": "Renderuj sliku", "cmd.render_status": "Napredak renderovanja", "cmd.select_clear": "Poništi izbor", + "cmd.settings_profile_apply": "Primijeni profil postavki", + "cmd.settings_profile_delete": "Izbriši profil postavki", + "cmd.settings_profile_export": "Izvezi profil postavki", + "cmd.settings_profile_import": "Uvezi profil postavki", + "cmd.settings_profile_list": "Profili postavki", + "cmd.settings_profile_rename": "Preimenuj profil postavki", + "cmd.settings_profile_save": "Sačuvaj profil postavki", "cmd.sheet_convert": "Pretvori u lim", "cmd.sheet_export_bend_table": "Izvezi tabelu savijanja", "cmd.sheet_export_dxf": "Izvezi razvijeni oblik kao DXF", @@ -1073,6 +1104,16 @@ "cmd.sheet_refold": "Ponovo presavij", "cmd.sheet_rules": "Popis pravila za lim", "cmd.sheet_unfold": "Razvij", + "cmd.sketch.construction_line": "Pomoćna prava", + "cmd.sketch.construction_line.angle": "Pomoćna prava pod uglom", + "cmd.sketch.construction_line.bisector": "Simetrala ugla", + "cmd.sketch.construction_line.horizontal": "Horizontalna pomoćna prava", + "cmd.sketch.construction_line.parallel": "Paralelna pomoćna prava", + "cmd.sketch.construction_line.perpendicular": "Okomita pomoćna prava", + "cmd.sketch.construction_line.tangent_at": "Tangenta u tački krive", + "cmd.sketch.construction_line.tangent_external": "Tangenta kroz vanjsku tačku", + "cmd.sketch.construction_line.tangent_two": "Zajednička tangenta", + "cmd.sketch.construction_line.vertical": "Vertikalna pomoćna prava", "cmd.sketch_arc3d": "3D luk", "cmd.sketch_arc_center_point": "Luk iz centra", "cmd.sketch_arc_tangent": "Tangentni luk", @@ -1105,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Veličina crteža", "cmd.sketch_edit": "Uredi skicu", "cmd.sketch_edit_text": "Uredi tekst", + "cmd.sketch_equation_curve": "Kriva iz jednačine", + "cmd.sketch_equation_curve_edit": "Uredi krivu iz jednačine", "cmd.sketch_export_dxf": "Sačuvaj kao DXF", "cmd.sketch_finish_auto_constrain": "Završi s automatskim ograničenjima", "cmd.sketch_fit_curves_to_section": "Uklopi krive u presjek mreže", @@ -1201,6 +1244,7 @@ "cmd.view.render_browser": "Stablo renderovanja", "cmd.view.render_dock": "Premjesti panel renderovanja", "cmd.view.render_focus": "Prikaz fokusa renderovanja", + "cmd.view.status_items": "Stavke statusa prozora prikaza", "cmd.view_ambient_occlusion": "Ambijentalna okluzija", "cmd.view_angle_readout": "Uglovi prikaza", "cmd.view_apply": "Vrati imenovani prikaz", @@ -1222,6 +1266,7 @@ "cmd.view_normal_to": "Okomito na izbor", "cmd.view_previous_tab": "Prethodna kartica", "cmd.view_rename": "Preimenuj imenovani prikaz", + "cmd.view_render_frame": "Pomoćne linije kadra renderovanja", "cmd.view_right": "Pogled zdesna", "cmd.view_save": "Novi imenovani prikaz", "cmd.view_shots_strip": "Traka kadrova", @@ -1232,10 +1277,14 @@ "color.copy": "Kliknite da kopirate vrijednosti boje", "color.edit": "Kliknite da uredite boju", "color.gamma": "Prikaz vrijednosti boje u gama prostoru 0-255", + "color.hex": "Hex", + "color.hex_help": "Unesite #rrggbb ili #rrggbbaa; šest cifara zadržava neprozirnost", + "color.hex_hint": "#rrggbb", "color.hue": "Nijansa", "color.linear": "Prikaz vrijednosti boje u linearnom prostoru 0-1", "color.saturation_value": "Zasićenost i vrijednost", "color.selected": "Izabrana boja", + "config.column_filter_empty": "Nema odgovarajućih kolona", "contact.components_count": "Komponente: {count}", "contact.enabled_label": "omogućeno", "coupling.gear_1": "Zupčanik 1", @@ -1350,8 +1399,20 @@ "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.mit": "MIT licenca", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Napravite beskonačnu horizontalnu pomoćnu pravu kroz tačku. Njeno ograničenje horizontalnosti ostaje na snazi pri kasnijim izmjenama.", + "help.sketch.construction_line.parallel": "Izaberite pravu liniju skice, pa postavite paralelnu beskonačnu pomoćnu pravu. Desnim klikom izaberite drugu referencu.", + "help.sketch.construction_line.perpendicular": "Izaberite pravu liniju skice, pa postavite okomitu beskonačnu pomoćnu pravu. Desnim klikom izaberite drugu referencu.", + "help.sketch.construction_line.tangent_at": "Napravite tangentu u tački na krugu ili kružnom luku, ili u krajnjoj tački otvorenog splajna ili konike. Izaberite krivu, pa tačku dodira.", + "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.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.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.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.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.", @@ -1480,6 +1541,21 @@ "joint.one_component": "Ovi dijelovi su tijela jedne komponente: {bodies}. Spoj pomjera jednu komponentu u odnosu na drugu, pa svaki dio treba svoju komponentu.", "joint.rigid_group_one_component": "Sva tijela ({count}) su u jednoj komponenti (ili u samom dizajnu): prvo koristite {command}.", "joint.rigid_group_select_components": "Izaberite dvije ili više komponenti.", + "joint_move.blocked_hint": "Provjerite vrstu spoja (npr. Kruti), učvršćenje, granice spoja i vrijednosti koje pokreću parametri.", + "joint_move.blocked_title": "Ovaj dio se ne može pomjeriti", + "joint_move.coarse": "Korak rotacije blizu ose (°)", + "joint_move.distance": "Udaljenost od početka", + "joint_move.done": "Gotovo", + "joint_move.fine": "Korak rotacije dalje od ose (°)", + "joint_move.fixed": "Ovaj dio nema pomičnu osu spoja.", + "joint_move.help": "Pomjeri — prevucite strelicu ili prsten. Esc otkazuje trenutno povlačenje.", + "joint_move.legacy_hint": "Isključite da biste koristili prethodni panel Pomjeri.", + "joint_move.precision": "Povucite izvan isprekidane vodilje za finu rotaciju.", + "joint_move.rotation": "Rotacija od početka", + "joint_move.select": "Izaberite lice ili jedan dio sklopa, a zatim izaberite Pomjeri.", + "joint_move.settings": "Pomjeranje u sklopu", + "joint_move.step": "Korak", + "joint_move.use_handles": "Koristi ručice kretanja spojeva za Pomjeri u sklopu", "keymap.command_id": "ID komande", "keymap.empty": "Nema odgovarajućih komandi", "keymap.use_anyway": "Ipak koristi {key} ovdje", @@ -1574,6 +1650,8 @@ "nav.maya.help": "Alt+lijevo povlačenje orbitira, Alt+srednje pomjera, Alt+desno zumira; srednje povlačenje također pomjera", "nav.openscad.help": "Povlačenje lijevim dugmetom po praznom prostoru orbitira, Shift+povlačenje lijevim bira okvirom, povlačenje geometrije skice je uređuje; povlačenje desnim ili srednjim dugmetom pomjera, Shift+desno ili Shift+srednje zumira", "nav.perspective.sketch": "Uređivanje skice koristi ortografski prikaz. Završite skicu da prebacite na perspektivu.", + "nav.right_drag_orbits.help": "Isključeno: povlačenje desnim dugmetom ne dira kameru, a desno dugme samo otvara kontekstni meni. Izbor mapiranja miša SolidWorks to isključuje.", + "nav.right_drag_orbits.label": "Povlačenje desnim dugmetom orbitira", "nav.solidworks.help": "Povlačenje srednjim dugmetom orbitira, Ctrl+srednje pomjera, Shift+srednje zumira, povlačenje desnim orbitira", "nav.tinkercad.help": "Povlačenje desnim dugmetom orbitira, povlačenje srednjim ili Shift+desnim pomjera", "nav.touchpad.help": "Držite Shift i pomjerajte pokazivač za pomjeranje prikaza; držite Alt/Option i pomjerajte ga za orbitiranje. Dugme miša nije potrebno. Vertikalno skrolovanje zumira.", @@ -1595,6 +1673,9 @@ "navbar.custom.customise": "Prilagodi navigacijsku traku…", "navbar.custom.design": "Dizajn", "navbar.custom.duplicate": "Ovo mjesto je već na traci.", + "navbar.custom.edge": "Rub", + "navbar.custom.edge_bottom": "Dolje", + "navbar.custom.edge_top": "Gore", "navbar.custom.fade": "Izblijedi", "navbar.custom.full": "Uklonite jedno mjesto da biste dodali drugo.", "navbar.custom.hidden_design": "Skriveno u Dizajnu", @@ -1701,6 +1782,7 @@ "parts.dimension.thickness": "Debljina", "parts.dimension.width": "Širina", "parts.family.bearing": "Kuglični ležaj s dubokim žlijebom (omotač)", + "parts.family.button_head_cap_screw": "Vijak s poluokruglom glavom i imbusom", "parts.family.dowel_pin": "Cilindrični zatik", "parts.family.hex_bolt": "Vijak sa šestougaonom glavom", "parts.family.hex_nut": "Šestougaona matica", @@ -1759,6 +1841,11 @@ "prefs.bottom_centre": "Dolje u sredini", "prefs.bottom_right": "Dolje desno", "prefs.browser": "Stablo modela", + "prefs.browser_eye_left": "Oko pored strelice za skupljanje", + "prefs.browser_eye_left_note": "Redovi stabla modela prikazuju oko za prikazivanje/skrivanje pored strelice za skupljanje, kao ranije, umjesto u koloni na desnom kraju redova.", + "prefs.browser_reveal_on_hover": "Prikaži pri prelasku mišem", + "prefs.browser_show_note": "Prikazano drži stablo modela na ekranu. Prikaži pri prelasku mišem drži ga skrivenim dok pokazivač ne pređe preko njegovog mjesta ili tankog jezička na njegovom lijevom rubu, a ostaje otvoreno dok je pokazivač u njemu ili dok je otvoren neki meni iz njega.", + "prefs.browser_shown": "Prikazano", "prefs.built_in": "Ugrađeno", "prefs.button_left": "Povlačenje lijevim dugmetom", "prefs.button_middle": "Povlačenje srednjim dugmetom", @@ -1806,6 +1893,8 @@ "prefs.graphics_card_restart": "Stupa na snagu nakon ponovnog pokretanja SolidCrafta. WGPU_ADAPTER_NAME ima prednost nad ovim izborom.", "prefs.graphics_note": "Žrtvujte brzinu za glatkoću. Svaki izbor pokazuje svoj učinak na primjeru.", "prefs.graphics_presets_note": "Unaprijed postavljene vrijednosti podešavaju sve izbore ispod odjednom.", + "prefs.hide_constraint_glyphs_zoomed_out": "Sakrij simbole ograničenja kada je prikaz umanjen", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Simboli ograničenja nestaju iz prikaza kada je skica toliko umanjena da bi je zatrpali. Isključeno: ostaju fiksne veličine na ekranu pri svakom zumiranju.", "prefs.history": "Vremenska traka", "prefs.history_origin_bodies": "Prikaži ishodište i tijela na vrhu", "prefs.history_origin_bodies_note": "Vremenska traka lijevo ili ispod stabla modela počinje folderom Tijela aktivne komponente i Ishodištem, s njihovim okom i menijima kao u stablu modela. Vremenska traka na dnu ostaje nepromijenjena.", @@ -1829,6 +1918,8 @@ "prefs.layout_note": "Kompaktno pričvršćuje panele uz ivice prozora. Prozirnost stabla modela dostupna je u opciji Plutajuće. Stranu Asistenta promijenite na stranici Asistent.", "prefs.left": "Lijevo", "prefs.left_click": "Lijevi klik", + "prefs.line_drag_draws_one": "Pritisak i povlačenje crta jednu liniju", + "prefs.line_drag_draws_one_note": "U alatu Linija pritisak, povlačenje i puštanje crta jednu liniju od mjesta pritiska do mjesta puštanja i tu se završava. Klikovi i dalje crtaju lanac. Isključeno: povlačenje postavlja tačku tamo gdje se pusti, kao i klik.", "prefs.linked_designs": "Povezani dizajni", "prefs.live_input": "Ulaz uživo", "prefs.live_preview": "Pregled uživo", @@ -1842,6 +1933,9 @@ "prefs.max_file_size_step": "STEP i uvezene datoteke", "prefs.max_file_size_web": "Web aplikacija otvara STEP i uvezene datoteke do 512 MB i dizajne do 256 MB: kartica preglednika ne može držati veće datoteke. Desktop aplikacija može povećati ove granice.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Velike ikone", + "prefs.menu_list": "Popis", + "prefs.menus": "Meniji", "prefs.minutes": "minuta", "prefs.minutes_suffix": " min", "prefs.mouse": "Miš i trackpad", @@ -1861,14 +1955,40 @@ "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.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", "prefs.placement": "Postavljanje", "prefs.prefer_free_sketch_points": "Daj prednost pokretnim tačkama skice kada se tačke preklapaju", "prefs.preview_choices": "Pregled vaših izbora", + "prefs.print_face_highlight": "Isticanje površine za štampu", + "prefs.print_face_highlight_tip": "Koliko se jako površina za štampu tijela boji i obrubljuje u prikazu. 0 % ne prikazuje isticanje.", + "prefs.profile_active": "Aktivni profil", + "prefs.profile_applied": "Profil je primijenjen.", + "prefs.profile_changed": "Postavke su promijenjene otkako je profil sačuvan. Sačuvaj ih upisuje u profil.", + "prefs.profile_deleted": "Profil je izbrisan.", + "prefs.profile_export": "Izvezi…", + "prefs.profile_exported": "Profil je izvezen.", + "prefs.profile_imported": "Profil je uvezen. Izaberite ga pod Aktivni profil da se prebacite na njega.", + "prefs.profile_name_hint": "Naziv profila, npr. Laptop", + "prefs.profile_rename": "Preimenuj", + "prefs.profile_renamed": "Profil je preimenovan.", + "prefs.profile_save": "Sačuvaj", + "prefs.profile_saved": "Trenutne postavke su sačuvane u profil.", + "prefs.profile_skipped": "Profil je primijenjen; postavke koje su zadržale trenutnu vrijednost: {n} (pogledajte zapisnik).", + "prefs.profiles": "Profili postavki", + "prefs.profiles_note": "Prečice, traka s alatima, navigacija, radijalni meni i postavke interfejsa čuvaju se zajedno pod jednim nazivom i mijenjaju odjednom.", + "prefs.project_options_dialog": "Dijalog opcija za projektovanje", + "prefs.project_options_dialog_note": "Uključeno: prije projektovanja izaberite Geometrija ili Tijela i Veza projekcije. Isključeno: zadržava se prethodni tok rada s direktnim izborom.", "prefs.quality_default": "podrazumijevano", "prefs.quality_fastest": "najbrže", "prefs.quality_slowest": "najsporije", "prefs.quality_smoother": "glađe", + "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}", + "prefs.ribbon_reveal": "Prikaži rezultate pretrage na traci", + "prefs.ribbon_reveal_note": "Zadržavanje pokazivača iznad rezultata u Pretraži komande pokazuje gdje se komanda nalazi na traci: njenu karticu, njeno dugme ili njen red u padajućem meniju panela. Komanda pokrenuta iz pretrage tamo trepće, a pokrenuta komanda iz padajućeg menija označava svoj panel.", "prefs.right_click": "Desni klik", "prefs.saving": "Automatsko čuvanje i oporavak", "prefs.scale_note": "Također Ctrl + / Ctrl − u prozoru. Dugmad su 28 px pri 100%.", @@ -1877,18 +1997,29 @@ "prefs.search_empty": "Nema odgovarajućih postavki", "prefs.select": "Izbor", "prefs.select_one_item": "Izaberi jednu stavku", + "prefs.select_other_hidden": "Izaberi skrivenu geometriju pomoću Izaberi drugo", + "prefs.select_other_hidden_help": "Uključeno: obuhvata skrivena tijela, skice i zaklonjene ivice u blizini pokazivača. Isključeno: koristi se izvorni popis vidljive geometrije ispod pokazivača.", "prefs.selecting": "Biranje", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift samo dodaje; Ctrl / Cmd uključuje i isključuje", "prefs.shift_add_note": "Uključeno: Shift dodaje, Ctrl / Cmd uključuje i isključuje. Isključeno (podrazumijevano): oba uključuju i isključuju.", "prefs.shortcuts": "Prečice", + "prefs.single_surface_analysis": "Koristi samo jednu analizu površi", + "prefs.single_surface_analysis_help": "Kada je isključeno, sjenčenje površi i Kontinuitet mogu ostati uključeni zajedno. Uključivanje zadržava posljednju pokrenutu ili promijenjenu analizu.", + "prefs.sketch_align_points": "Poravnaj s drugim tačkama skice tokom crtanja", + "prefs.sketch_align_points_note": "Tokom crtanja tačka se istovremeno poravnava vodoravno s jednom tačkom skice i okomito s drugom, uz tačkastu pomoćnu liniju, a klik zadržava oba poravnanja. Isključeno: samo s prethodnom tačkom, po jedna osa.", "prefs.sketch_conflict_colour": "Geometrija u sukobu u boji sukoba", "prefs.sketch_conflict_colour_note": "Dok su ograničenja skice u sukobu, krive i tačke koje drži ograničenje koje ne uspijeva crtaju se također u boji sukoba, ne samo njegov simbol ili kota. Isključeno: zadržavaju uobičajene boje.", + "prefs.sketch_construction_lines": "Prošireni alati za pomoćne prave", "prefs.sketch_exit_view": "Kada se skica završi", "prefs.sketch_exit_view.keep": "Zadrži trenutni prikaz", "prefs.sketch_exit_view.previous": "Vrati se na prethodni prikaz", "prefs.sketch_exit_view.unless_rotated": "Vrati se osim ako je rotirano", "prefs.sketch_exit_view_note": "Kuda prikaz ide nakon Završi skicu, Otkaži ili dvostrukog Esc.", + "prefs.sketch_open_look_at": "Okreni kameru prema skici kad se otvori", + "prefs.sketch_open_look_at_note": "Isključeno: kad napravite ili uredite skicu, prikaz ostaje onakav kakav jest. Pogledaj okreće prikaz prema skici kad to želite.", + "prefs.sketch_recent_snap": "Automatski privlači na nedavne centre i središnje tačke", + "prefs.sketch_recent_snap_note": "Tokom crtanja pokazivač se privlači tamo gdje se sijeku pomoćne linije dvaju posljednjih centara krugova ili središnjih tačaka pravih ivica. Isključeno: te pomoćne linije služe samo za prikaz i pokazivač se ne pomjera.", "prefs.sketch_selection": "Skica i izbor", "prefs.sketch_selection_heading": "Skica i izbor", "prefs.sketch_selection_note": "Kako se skice privlače i kako klikovi biraju.", @@ -1933,7 +2064,14 @@ "prefs.toast_place": "Obavještenja", "prefs.toggle_item": "Dodaj stavku ili je ponovo ukloni", "prefs.toolbar": "Traka s alatima", + "prefs.toolbar_tabs": "Kartice trake s alatima", "prefs.top": "Gore", + "prefs.trackpad_zoom_modifier": "Zumiranje skrolovanjem s dva prsta uz držanje tipke", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Držite samo ovu tipku da zumirate prema pokazivaču vertikalnim skrolovanjem. Isključeno zadržava postojeće ponašanje skrolovanja.", + "prefs.trackpad_zoom_modifier.off": "Isključeno", "prefs.translucent": "Prozirno", "prefs.under_browser": "Ispod stabla modela", "prefs.under_cursor": "Ispod pokazivača", @@ -1943,13 +2081,24 @@ "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.", "prefs.viewport": "Prozor prikaza", + "prefs.viewport_analysis_status": "Prikaži status analize u prozoru prikaza", + "prefs.viewport_analysis_status_help": "Prikazuje aktivne analize iznad prozora prikaza, otvara njihove legende iz svake stavke, a tipka Esc isključuje najnoviju analizu. Isključite da biste vratili legende u uglovima i prethodno ponašanje tipke Esc.", + "prefs.viewport_background": "Pozadina", + "prefs.viewport_background_colour": "Boja", + "prefs.viewport_background_theme": "Gradijent teme", "prefs.wheel": "Točkić", "prefs.wheel_trackpad": "Točkić i trackpad", "prefs.workspace": "Radni prostor", + "prefs.zoom_stop_at_model": "Zaustavi zumiranje na modelu", + "prefs.zoom_stop_at_model_tip": "3D miš i aplikacija uređaja zumiraju prema površini u sredini prikaza; zumiranje povlačenjem drži tačku na kojoj je povlačenje počelo. Isključeno: zumiranje može proći kroz model. Točkić i štipanje zasebno koriste Zumiranje prema pokazivaču", "prefs.zoom_toward_cursor": "Zumiranje prema pokazivaču", "properties.title": "Svojstva: {name}", "recovery.days_ago": "prije {count} dana", "recovery.detail": "{file} · operacija: {features} · automatski sačuvano {when}", + "recovery.discard_all": "Odbaci sve", + "recovery.discard_all_detail": "Ovo se ne može poništiti.", + "recovery.discard_all_title": "Odbaciti automatska čuvanja ({count})?", + "recovery.discard_all_title_one": "Odbaciti 1 automatsko čuvanje?", "recovery.hours_ago": "prije {count} h", "recovery.just_now": "upravo sada", "recovery.min_ago": "prije {count} min", @@ -2034,6 +2183,7 @@ "render.appearance_editor.weight": "Težina", "render.appearance_physics_separate": "Vizuelni izgled ne dodjeljuje gustinu, toplotne podatke niti druga inženjerska svojstva.", "render.appearances": "Izgledi", + "render.assign_help": "Kliknite za primjenu na izbor; prevucite za pregled i ispustite ili kliknite kada ništa nije izabrano da bojite.", "render.availability_native": "Izvorni sastav; pregledajte prije primjene", "render.availability_projection": "Podržana projekcija; pogledajte opseg i izostavke", "render.availability_reference": "Referentni podaci; nije automatska dodjela", @@ -2072,6 +2222,7 @@ "render.bloom_threshold": "Prag linearnih svijetlih dijelova", "render.browse_capabilities": "Pregledaj kompletan katalog mogućnosti", "render.browse_environments": "Pregledaj izvorna HDR okruženja", + "render.browse_looks": "Pregledaj…", "render.browse_native_scenes": "Pregledaj izvorne studije", "render.browse_physical": "Pregledaj fizičke reference", "render.browse_quality": "Pregledaj specifikacije kvaliteta", @@ -2095,6 +2246,82 @@ "render.check_opaque": "Prozirnost: samo neprozirne površi; alfa pozadine je podržana.", "render.check_sorted_alpha": "Prozirnost: aproksimacija sortirane alfe.", "render.check_transmission": "Prozirnost: fizička transmisija.", + "render.children.absolute_floor": "Apsolutni donji prag", + "render.children.adaptive": "Adaptivno", + "render.children.add_look_reason": "Dodajte izgled da biste uredili njegovo uključivanje i jačinu.", + "render.children.all_shots": "Svi kadrovi", + "render.children.bloom": "Sjaj", + "render.children.check": "Provjera", + "render.children.check_now": "Provjeri sada", + "render.children.chromatic": "Hromatski pomak", + "render.children.compact": "Kompaktno", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopiraj", + "render.children.copy_job": "Kopiraj JSON posla", + "render.children.copy_request": "Kopiraj JSON zahtjeva", + "render.children.coverage_error": "Greška pokrivenosti", + "render.children.cpu": "CPU praćenje zraka", + "render.children.denoise": "Smanji šum na završenoj slici", + "render.children.depth": "Dubina putanje", + "render.children.distortion": "Izobličenje", + "render.children.edge_samples": "Uzorci ivica", + "render.children.effects_banner": "Ovi efekti zahtijevaju GPU praćenje putanja. Otvaranje ovoga nikad ne mijenja metodu.", + "render.children.enable_adaptive": "Omogućite nezavisne probne uzorke da biste uredili adaptivna ograničenja.", + "render.children.enable_bloom": "Omogućite sjaj da biste uredili njegovu jačinu, prag i poluprečnik.", + "render.children.finishing": "Završna obrada", + "render.children.flare": "Odsjaj", + "render.children.fresnel": "Fresnelovi slojevi", + "render.children.ghosts": "Duhovi prstenova", + "render.children.glass": "Transport kroz staklo", + "render.children.go_to": "Idi na {editor}", + "render.children.grain": "Zrno", + "render.children.halo": "Oreol", + "render.children.hdr": "HDR", + "render.children.keep_master": "Zadrži sirovi linearni master", + "render.children.last_result": "Posljednje: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efekti objektiva", + "render.children.look_summary": "{tone} · izgledi: {count}", + "render.children.looks": "Kreativni izgledi", + "render.children.memory": "Memorija", + "render.children.method": "Metoda", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Potrebno je CPU praćenje zraka. Otvaranje ovoga nikad ne mijenja metodu.", + "render.children.nested": "Potvrđeni ugniježđeni dielektrici", + "render.children.not_checked": "Nije provjereno", + "render.children.path": "Praćenje putanja", + "render.children.pilots": "Probni uzorci", + "render.children.preview": "Podaci pregleda", + "render.children.progressive_reason": "Izaberite Progresivno da biste uredili broj uzoraka i sjeme.", + "render.children.radius": "Poluprečnik", + "render.children.reflect_depth": "Dubina refleksije", + "render.children.reflect_samples": "Uzorci refleksije", + "render.children.regrade": "Ponovna gradacija", + "render.children.relative_error": "Relativna greška", + "render.children.render": "Renderovanje", + "render.children.render_shot": "Renderuj kadar", + "render.children.renderer": "Renderer", + "render.children.renderers": "Rendereri", + "render.children.request": "Ovaj zahtjev", + "render.children.reset_finishing": "Resetuj završnu obradu", + "render.children.review_quality": "Pregledaj specifikaciju kvaliteta…", + "render.children.rgb_gain": "RGB pojačanje", + "render.children.samples": "Uzorci", + "render.children.sampling": "Uzorkovanje", + "render.children.saturation": "Zasićenost", + "render.children.scene_summary": "Tijela: {bodies} · trouglovi: {triangles}", + "render.children.seed": "Sjeme", + "render.children.sharpening": "Izoštravanje", + "render.children.single_image": "Jedna slika", + "render.children.soft_focus": "Meki fokus", + "render.children.streak": "Pruga", + "render.children.strength": "Jačina", + "render.children.studio": "Studio", + "render.children.threshold": "Prag", + "render.children.tone": "Ton", + "render.children.under_hood": "Ispod haube", + "render.children.vignette": "Vinjeta", + "render.children.workstation": "Radna stanica", + "render.choose_look": "Prvo izaberite izgled u Izgledima", "render.chromatic_offset": "Hromatski pomak ivica", "render.clear_stage": "Ukloni sačuvanu scenu", "render.copy_request": "Kopiraj JSON", @@ -2346,8 +2573,33 @@ "render.flare_strength": "Jačina odsjaja svijetlih dijelova", "render.flare_threshold": "Linearni prag odsjaja scene", "render.fold_browser": "Skupi stablo modela", + "render.frame_aspect": "Omjer", + "render.frame_centre_mark": "Oznaka centra", + "render.frame_copy_limit": "Nema slobodnog naziva za duplikat kadra", + "render.frame_copy_name": "{name} kopija {number}", + "render.frame_custom": "Prilagođeno", + "render.frame_distance_unit": " × veličina", + "render.frame_duplicate": "Dupliciraj", + "render.frame_exposure": "Ekspozicija", + "render.frame_fov": "Vidno polje", + "render.frame_guides": "Pomoćne linije", + "render.frame_invalid_aspect": "Neispravan predložak omjera", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Naziv", + "render.frame_output_size": "Veličina izlaza", + "render.frame_photo": "3:2 · Fotografija", + "render.frame_portrait": "4:5 · Portret", "render.frame_presets": "Predlošci kadra", "render.frame_presets_pending": "Stiže s uređivačem kadrova", + "render.frame_projection": "Projekcija", + "render.frame_render_shot": "Renderuj ovaj kadar", + "render.frame_rule_thirds": "Pravilo trećina", + "render.frame_safe_area": "Sigurna zona", + "render.frame_shot_title": "Kadar · {name}", + "render.frame_square": "1:1 · Kvadrat", + "render.frame_tall": "9:16 · Vertikalno", + "render.frame_thirds": "Trećine", + "render.frame_wide": "16:9 · Široki ekran", "render.gpu_depth": "Maksimalna dubina putanje", "render.gpu_options_help": "Uzorci, dubina, sjeme i ekspozicija čuvaju se s ovom postavkom. Čuva se samo aktivna metoda slike.", "render.gpu_path_failed": "GPU praćenje putanja nije dostupno", @@ -2375,6 +2627,7 @@ "render.heading_results": "REZULTATI", "render.heading_shots": "KADROVI", "render.hide_shots_label": "Sakrij traku kadrova", + "render.hover_look": "{object} · {look} · kliknite za izbor", "render.image_advanced": "Napredne GPU kontrole", "render.image_cancelled": "Renderovanje je otkazano", "render.image_cancelling": "Otkazivanje", @@ -2521,17 +2774,95 @@ "render.library_uniform_ack": "Koristi jednoličnu aproksimaciju bez prostornih ili naprednih efekata", "render.library_usable": "Izvršne projekcije", "render.library_users": "Korisnici", + "render.light_aim": "Usmjeri na", "render.light_anchor_pending": "Promjena koordinatnog sistema svjetla još nije podržana.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Izaberite CPU praćenje zraka ili izričit GPU studio da biste uređivali svjetla. Svjetla starog GPU studija ostaju samo za čitanje.", + "render.light_behaviour": "PONAŠANJE", + "render.light_default_fill": "Popunjavajuće", + "render.light_default_key": "Glavno", + "render.light_default_number": "Svjetlo {number}", + "render.light_default_rim": "Rubno", + "render.light_delete": "Izbriši", + "render.light_distance": "Udaljenost", + "render.light_duplicate": "Dupliciraj", + "render.light_elevation": "Elevacija", + "render.light_gpu_punctual": "Tačkasta svjetla i reflektori dostupni su uz CPU praćenje zraka.", + "render.light_hard_shadow": "Ovo svjetlo ima oštre sjene; mekoća emitera nije dostupna.", + "render.light_intensity": "Intenzitet", + "render.light_missing": "Ovo svjetlo više ne postoji. Izaberite svjetlo u stablu modela.", + "render.light_place_highlight": "Postavi prema odsjaju", + "render.light_place_pending": "Postavljanje prema odsjaju zahtijeva izbor svjetla u prozoru prikaza i još nije podržano.", + "render.light_position": "POLOŽAJ", + "render.light_preview_help": "Prikaz uživo je bez sjena; površinska svjetla koriste aproksimaciju uzorkom iz centra. Renderovanje koristi potpuni model svjetla.", + "render.light_product_centre": "Centar proizvoda", + "render.light_size": "Veličina", + "render.light_softness": "Mekoća", + "render.light_temperature": "Temperatura boje", + "render.light_temperature_help": "Približna nijansa crnog tijela; čuva se kao linearni RGB.", + "render.light_type": "Vrsta", + "render.light_type_area": "Površinsko", + "render.light_type_point": "Tačkasto", + "render.light_type_spot": "Reflektor", + "render.light_type_sun": "Sunce", + "render.light_view_only": "Samo ovo svjetlo i isključivanje svjetla utiču na ovaj prikaz i njegova renderovanja; nikad se ne čuvaju u dizajnu.", + "render.light_world_origin": "Ishodište svijeta", + "render.lighting_aim_point": "Tačka usmjeravanja", + "render.lighting_apply": "Primijeni postavku", + "render.lighting_area_samples": "Uzorci površinskog svjetla", + "render.lighting_budget": "Budžet", + "render.lighting_budget_help": "Brojevi su samo za čitanje. Dodajte, duplicirajte ili izbrišite svjetla da ih promijenite; metoda određuje ograničenja.", + "render.lighting_colour": "Boja", + "render.lighting_consent": "Zamijeni usmjerena svjetla ovom postavkom", + "render.lighting_custom": "Prilagođena svjetla", + "render.lighting_custom_help": "Izbor opcije Prilagođeno pretvara postavku u svjetla koja se mogu uređivati, svako u svom redu u stablu.", + "render.lighting_direction": "Smjer X Y Z", + "render.lighting_direction_help": "Ili upišite smjer prema svjetlu (X Y Z).", + "render.lighting_distance_unit": " × veličina", + "render.lighting_edge_u": "Vektor stranice U", + "render.lighting_edge_v": "Vektor stranice V", + "render.lighting_falloff": "Slabljenje", + "render.lighting_falloff_help": "Intenzitet se navodi na referentnoj udaljenosti.", + "render.lighting_height": "Visina V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Isključi ovo svjetlo", + "render.lighting_point": "Tačka", + "render.lighting_preset": "Postavka studija", + "render.lighting_preset_cpu": "Usmjerene postavke studija zahtijevaju CPU praćenje zraka. GPU osvjetljenje koristi izričita sunčeva i površinska svjetla.", + "render.lighting_preset_row": "Postavka", + "render.lighting_radiance": "Radijansa", + "render.lighting_reference": "Referenca", + "render.lighting_review_help": "Zamjenjuje popis usmjerenih svjetala. Zadržava tačkasta i površinska svjetla, okruženje, sjene, kameru i sve ostale postavke kadra.", + "render.lighting_review_title": "Pregledaj postavku osvjetljenja", + "render.lighting_samples_cpu": "Uzorci površinskog svjetla primjenjuju se na CPU praćenje zraka.", + "render.lighting_samples_need_area": "Dodajte CPU površinsko svjetlo da biste postavili uzorke površinskog svjetla.", + "render.lighting_samples_reason": "Uzorci površinskog svjetla moraju biti 1, 4, 16 ili 64.", + "render.lighting_shadow_cpu": "Prekidač sjena zahtijeva CPU praćenje zraka. GPU praćenje putanja uvijek baca sjene.", + "render.lighting_shadows": "Baci sjene", + "render.lighting_shadows_section": "Sjene i uzorkovanje", + "render.lighting_shape": "Oblik", + "render.lighting_solo": "Samo ovo svjetlo", + "render.lighting_source": "Izvor", + "render.lighting_source_section": "Izvor svjetla", + "render.lighting_spot_preserved": "Ovo tačkasto svjetlo zadržava sačuvani konus reflektora dok mu se ne promijeni vrsta.", + "render.lighting_sun_distance": "Smjer sunca ne zavisi od udaljenosti i tačke usmjeravanja.", + "render.lighting_unsupported": "nepodržano", + "render.lighting_used": "{used} od {limit}", + "render.lighting_width": "Širina U", + "render.lighting_world_position": "Svjetski X Y Z", "render.lights_fixed_to": "Svjetla vezana za", "render.linear_help": "Zadržava 32-bitni float RGBA master prije uklanjanja šuma, ekspozicije, mapiranja tonova, filtera i pozadine. Linearni sRGB/D65; pomnožen pokrivenošću. Dodatna memorija se izričito odobrava. Bez kompresije i dodatnih prolaza.", "render.linear_retain": "Zadrži sirovi linearni EXR", "render.linear_unavailable": "Izabrani završeni kadar nema zadržani EXR. Omogućite zadržavanje prije renderovanja; stariji PNG ne može vratiti sirovu radijansu.", "render.look_add": "Dodaj izgled boje", + "render.look_applied": "Primijenjeno: {name}", "render.look_bleach": "Po uzoru na bleach bypass", "render.look_cool": "Hladno", "render.look_cyanotype": "Po uzoru na cijanotipiju", "render.look_faded": "Izblijedjeli otisak", + "render.look_material": "Materijal", "render.look_mono": "Jednobojno", + "render.look_pasted": "Izgled je zalijepljen", "render.look_sepia": "Sepija", "render.look_strength": "Jačina izgleda", "render.look_teal_orange": "Tirkizno / narandžasto", @@ -2607,9 +2938,11 @@ "render.output_advanced": "Napredno", "render.output_backend_ready": "Spremno", "render.output_check": "Provjeri", + "render.output_check_failed": "Provjera · greška", "render.output_check_help": "Pokrenite provjeru da pregledate ovaj zahtjev bez pokretanja renderovanja.", "render.output_check_summary": "Provjera · Napomene: {count}", "render.output_copy": "Kopiraj", + "render.output_copy_report": "Kopiraj izvještaj", "render.output_destination": "Odredište", "render.output_destination_help": "Odredišnu datoteku birate pri izvozu završene slike.", "render.output_dimensions": "Veličina", @@ -2619,6 +2952,7 @@ "render.output_fit": "Uklopi", "render.output_format": "Format", "render.output_format_status": "PNG je tačna završena slika za prikaz. Neobavezni GPU EXR zadržava sirovu linearnu radijansu scene u formatu FLOAT32 prije filtera prikaza. Izvoz nikad ne zamjenjuje keš pregleda ni drugi posao.", + "render.output_grading": "Gradacija", "render.output_image": "Slika", "render.output_largest": "Najviše {width} × {height}", "render.output_lock_ratio": "Zaključaj omjer stranica", @@ -2628,6 +2962,8 @@ "render.output_quality_waiting": "čeka se specifikacija kvaliteta", "render.output_ratio_refused": "Povezana dimenzija premašuje ograničenje veličine ove metode. Prethodna veličina se zadržava; otključajte omjer ili izaberite manju vrijednost.", "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_ok": "Veličina u redu · {width} × {height} px", "render.output_swap_orientation": "Zamijeni širinu i visinu", "render.output_tools_inspector": "Veličinu i format uredite u inspektoru Izlaz.", @@ -2644,6 +2980,7 @@ "render.page_output": "Izlaz", "render.page_scene": "Scena", "render.paint_label": "Oboji", + "render.painting_with": "Bojenje izgledom: {name}", "render.passive_stage": "Podloga i pasivna scena", "render.passive_stage_help": "Konačni pasivni paneli pripadaju samo renderovanim slikama. Ne dodaju CAD tijela ni masu. Postavljanje je fiksno nakon primjene; presijecanje granica proizvoda se odbija. CPU ne daje difuzno odbijeno osvjetljenje.", "render.perspective": "Perspektivna kamera", @@ -2665,6 +3002,7 @@ "render.pick_help": "Izaberite Tijelo, Lice ili Komponenta. Kliknite prazan prostor da očistite izbor; Shift ili Ctrl dodaje ili uključuje/isključuje. Isticanja se pojavljuju samo u živom prikazu proizvoda.", "render.pick_label": "Izaberi", "render.pick_up_label": "Preuzmi", + "render.pick_up_prompt": "Kliknite objekt da preuzmete njegov izgled", "render.pick_waiting": "Izbor proizvoda još nije podržan.", "render.post_contrast": "Kontrast", "render.post_gain_b": "Pojačanje plave", @@ -2704,13 +3042,17 @@ "render.radial_distortion": "Radijalno izobličenje", "render.radial_fit": "Uklopi proizvod", "render.radial_save": "Sačuvaj kadar", + "render.recent_looks": "Nedavni izgledi", "render.regrade_action": "Ponovna gradacija sirovog mastera (bez uklanjanja šuma)", "render.regrade_help": "Pravi zaseban PNG iz izabranog završenog sirovog mastera, koristeći ove kontrole prikaza. Ne prati zrake, ne mijenja zabilježenu veličinu, kameru ni svjetla i ne prepisuje EXR. Uklanjanje šuma se ne primjenjuje ponovo: sirovi masteri ne čuvaju njegove vodilje. Izvedena slika se i dalje može izvesti istim kontrolama posla.", "render.regrade_label": "Ponovna gradacija", "render.reload_settings": "Ponovo učitaj sačuvane postavke", + "render.remove_override": "Ukloni nadjačavanje", + "render.replace_all": "Zamijeni sve", "render.replace_ground": "Potvrdi zamjenu postojeće scene", "render.replace_label": "Zamijeni", "render.replace_saved_surface": "Zamijeni postojeću sačuvanu vezu za ovaj cilj", + "render.replace_with": "Zamijeni sa", "render.restore_camera": "Vrati sačuvanu kameru", "render.result_actual": "100%", "render.result_actual_help": "Jedan piksel slike po pikselu ekrana.", @@ -2762,14 +3104,28 @@ "render.saved_surface_remove": "Ukloni sačuvanu vezu", "render.saved_surfaces": "Sačuvane veze površina", "render.saved_surfaces_help": "Fiksirane ugrađene mape prate izabrani primjerak u lokalnim milimetrima. Zamijenjen ili nedostajući cilj je greška, nikad automatska ponovna dodjela.", + "render.scene_all_shots": "Svi kadrovi ({count})", "render.scene_apply": "Primijeni pregledani studio", + "render.scene_batch_reason": "Za Svi kadrovi potreban je red čekanja za renderovanje sačuvanih kadrova, koji još nije dostupan.", + "render.scene_check_now": "Provjeri sada", + "render.scene_check_passed": "Provjera uspješna · napomene: {notes}", + "render.scene_check_stale": "Provjera je zastarjela · provjerite ponovo", + "render.scene_check_unchecked": "Još nije provjereno · 0 napomena", "render.scene_consent": "Potvrdi zamjenu osvjetljenja, okruženja, ekspozicije i scene", + "render.scene_light_summary": "{environment} · svjetla: {lights}", + "render.scene_look_summary": "Izgledi: {looks} · nadjačavanja: {overrides}", + "render.scene_open_check": "Otvori provjeru", "render.scene_preserved_fields": "Kamera, uzorkovanje, dimenzije, optičke veze, CAD geometrija i inženjerski materijali se čuvaju.", "render.scene_preserves_design": "Sastavite osvjetljenje i pasivnu scenu za vidljivi predmet. Geometrija, inženjerski materijali i kamera ostaju odvojeni.", + "render.scene_ready": "Spremno za renderovanje", + "render.scene_reminder": "Scena čuva dizajn. Geometrija, inženjerska svojstva i CAD nikad se ne mijenjaju iz Renderovanja.", + "render.scene_reminders": "Podsjetnici", + "render.scene_renderer_ready": "{renderer}: spremno", "render.scene_replaces": "Zamjenjuje", "render.scene_review": "Razriješi i pregledaj", "render.scene_review_help": "Pregledajte kompletnu izvornu kompoziciju prije primjene. Nevezane napredne SCENE specifikacije se ne zamjenjuju.", "render.scene_scope_details": "Opseg kompozicije", + "render.scene_stages": "Faze", "render.search_appearances": "Pretraži {count} izgleda", "render.setup_legacy_environment": "Ova starija postavka koristi naslijeđenu rotaciju ili intenzitet okruženja. Te vrijednosti se zadržavaju; izričito ih pretvorite prije čuvanja punog posla slike.", "render.setup_name": "Naziv postavke", @@ -2782,7 +3138,22 @@ "render.shell_tab_camera": "KAMERA RENDERA", "render.shell_tab_lighting": "OSVJETLJENJE", "render.shell_tab_scene": "SCENA RENDERA", + "render.shot_aperture": "Otvor blende", + "render.shot_distance": "Udaljenost", + "render.shot_dof": "Dubinska oštrina", "render.shot_duplicate": "Dupliciraj sačuvani kadar", + "render.shot_edit_output": "Uredi u Izlazu", + "render.shot_exposure_reason": "Ekspozicija zahtijeva CPU praćenje zraka ili GPU praćenje putanja.", + "render.shot_exposure_section": "Ekspozicija", + "render.shot_focus_distance": "Udaljenost fokusa", + "render.shot_fov_reason": "Vidno polje zahtijeva perspektivnu projekciju.", + "render.shot_frame_subject": "Uokviri predmet", + "render.shot_invalid_camera": "Vrijednosti kamere moraju biti konačne i unutar raspona.", + "render.shot_lens": "Objektiv", + "render.shot_lens_enable_reason": "Omogućite dubinsku oštrinu da biste uredili otvor blende i udaljenost fokusa.", + "render.shot_lens_gpu_reason": "Dubinska oštrina zahtijeva GPU praćenje putanja.", + "render.shot_lens_perspective_reason": "Dubinska oštrina zahtijeva perspektivnu projekciju.", + "render.shot_look_selection": "Usmjeri na izbor", "render.shot_manager": "Sačuvani kadrovi", "render.shot_new_name": "Naziv novog kadra", "render.shot_remove": "Ukloni sačuvani kadar", @@ -2790,6 +3161,9 @@ "render.shot_remove_title": "Ukloniti sačuvani kadar?", "render.shot_rename": "Preimenuj sačuvani kadar", "render.shot_saved_only": "Prvo sačuvajte trenutni nacrt. Dupliciranje i preimenovanje čuvaju tačne sačuvane opcije i reference na resurse.", + "render.shot_selection_reason": "Izaberite tijelo ili lice prema kojem treba usmjeriti kameru.", + "render.shot_size": "Veličina", + "render.shot_target": "Cilj", "render.shots_camera_changed": "Kamera je promijenjena od čuvanja ovog kadra", "render.shots_name_help": "Unesite jedinstven naziv za trenutni kadar.", "render.shots_no_result": "Još nema renderovanja", @@ -3052,6 +3426,7 @@ "select an axis": "izaberite osu", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "prvo izaberite osu, pravu ili kružnu ivicu, ili lice cilindra, konusa ili torusa", "select bodies first": "prvo izaberite tijela", + "select components first": "prvo izaberite komponente", "select corner points, or the two lines of one corner": "izaberite tačke uglova ili dvije linije jednog ugla", "select one corner point, or the two lines of one corner": "izaberite jednu tačku ugla ili dvije linije jednog ugla", "select sheet edges first": "prvo izaberite ivice lima", @@ -3070,6 +3445,7 @@ "settings.double_click_components": "Dvaput kliknite komponentu da je aktivirate ili da uredite povezanu komponentu na mjestu", "settings.frame_rate_details_note": "Dodaje graf vremena posljednjih kadrova s brojem iscrtanih trouglova, histogram vremena kadrova i šta je GPU nacrtao. Kliknite broj kadrova u sekundi u prikazu da ih prikažete ili sakrijete.", "settings.frame_rate_note": "Prikazuje broj kadrova u sekundi u donjem desnom uglu prikaza.", + "settings.insert_lists_open_designs": "Umetni dizajn prvo navodi dizajne otvorene na drugim karticama", "settings.measure_axis_distances": "Prikaži udaljenosti X/Y/Z", "settings.measure_axis_distances_help": "Prikazuj komponente udaljenosti po svjetskim osama uz ukupnu udaljenost dok je Mjerenje otvoreno.", "settings.msaa_applies_on_restart": "Uglađivanje ivica primjenjuje se pri sljedećem pokretanju SolidCrafta", @@ -3094,6 +3470,10 @@ "shortcut.unpin": "Otkači", "sketch is missing": "skica nedostaje", "sketch.degrees_of_freedom": "Stepeni slobode: {count}", + "sketch.dimension_added_driven": "Skica već određuje tu kotu, pa je dodana kao zavisna (referentna) kota", + "sketch.dimension_applied": "Kota je primijenjena", + "sketch.dimension_applied_defined": "Kota je primijenjena; parametar `{name}` je definisan", + "sketch.dimension_applied_renamed": "Kota je primijenjena; parametar je nazvan `{name}`", "sketch.dof_left": "Preostali stepeni slobode: {count}", "sketch.dof_left_one": "Preostali stepeni slobode: {count}", "sketch.draw_curves": "Crtaj kao {linetype}", @@ -3128,6 +3508,8 @@ "sketch_text.use_font_file": "Koristi datoteku fonta {path}", "solid": "puno", "status.editing": "Uređivanje: {name}", + "status.navigation": "Mapiranje miša: {mapping}", + "status.navigation_tip": "Kako miš pomjera prikaz. Kliknite da izaberete drugo mapiranje", "status.under_constrained": "Nedovoljno ograničeno (stepeni slobode: {count})", "status.under_constrained_one": "Nedovoljno ograničeno ({count} stepen slobode)", "status.units_tip": "Jedinice dizajna. Kliknite da ih promijenite", @@ -3161,8 +3543,10 @@ "timeline.delete_folders_tip": "Zadrži njihove operacije i ugniježđene foldere u vremenskoj traci", "timeline.delete_folders_with_features_tip": "Izbriši foldere sa svim operacijama u njima i njihove ugniježđene foldere", "timeline.delete_many": "Izbriši izabrane operacije: {count}", + "timeline.edited_rolled_back": "Operacija {name} je promijenjena, ali je iza oznake vraćanja, pa se ne gradi: pomjerite oznaku iza nje da vidite rezultat", "timeline.feature_error": "Greška: {message}", "timeline.feature_hint": "Dvaput kliknite za uređivanje · povucite za promjenu redoslijeda · desni klik za više", + "timeline.feature_rolled_back": "Vraćeno: iza oznake vraćanja, pa se ne gradi", "timeline.feature_warning": "Upozorenje: {message}", "timeline.group": "Novi folder operacija", "timeline.move_many": "Premjesti stavke vremenske trake", @@ -3244,6 +3628,10 @@ "ui.3d_mouse_offer": "Kalibracija 3D miša ispravlja zamijenjene ili obrnute ose i podešava njegovu brzinu i osjećaj.", "ui.3d_mouse_offer_never": "Ne prikazuj više", "ui.3d_mouse_offer_swap": "Prebaci na Hidraw (direktni drajver)", + "ui.3d_mouse_orbit": "Orbitiranje", + "ui.3d_mouse_orbit_classic": "Klasično", + "ui.3d_mouse_orbit_tip": "Prema modelu: 3D miš okreće prikaz oko dijela modela koji je u sredini prikaza kada okretanje počne, označenog blijedom kuglom. Klasično: oko centra prikaza", + "ui.3d_mouse_orbit_traced": "Prema modelu", "ui.3d_mouse_reading": "Očitanje", "ui.3d_mouse_reset_axes": "Resetuj dodjelu osa", "ui.3d_mouse_reset_view": "Resetuj prikaz", @@ -3280,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Zakrenite kapicu suprotno od kazaljke na satu, držite je, a zatim pustite.", "ui.3d_mouse_switch_cap": "Kalibriši kao Povratak u centar", "ui.3d_mouse_switch_trackball": "Kalibriši kao trackball", + "ui.3d_mouse_use_direct": "Koristi direktni drajver", + "ui.3d_mouse_use_direct_tip": "Čitaj 3D miš direktno (hidraw) umjesto preko spacenavd.", "ui.3d_mouse_view_axis": "Osa prikaza", "ui.3d_mouse_view_off": "Isključeno", "ui.3d_mouse_view_off_tip": "Profil drajvera 3D miša isključuje ovu osu, pa njeno kretanje ne pomjera prikaz. Izbor kretanja druge ose ovdje ih zamjenjuje, a to kretanje prestaje pomjerati prikaz.", @@ -3596,6 +3986,7 @@ "ui.circle": "Krug", "ui.circular_pattern": "Kružni niz", "ui.clear": "Očisti", + "ui.clear_print_face": "Ukloni površinu za štampu", "ui.clear_recent": "Očisti nedavno", "ui.clear_the_selection": "Poništi izbor", "ui.click_selects_whole_bodies": "Klik bira cijela tijela", @@ -3630,6 +4021,15 @@ "ui.constant": "Konstantno", "ui.constraints_conflict": "Ograničenja su u sukobu", "ui.construction": "Pomoćna", + "ui.construction.angle_hint": "Pomoćna prava: kliknite tačku, pa upišite ugao ili izaberite smjer", + "ui.construction.bisector_hint": "Simetrala: izaberite dvije prave linije koje se sijeku, pa željeno rješenje", + "ui.construction.common_hint": "Zajednička tangenta: izaberite dva kruga ili luka, pa željeno rješenje", + "ui.construction.contact_hint": "Tangenta: izaberite krug/luk ili otvoreni splajn/koniku, pa tačku dodira (krive slobodnog oblika: krajnje tačke)", + "ui.construction.external_hint": "Tangenta: izaberite krug ili luk, vanjsku tačku, pa željeno rješenje", + "ui.construction.free_hint": "Pomoćna prava: kliknite dvije tačke; desnim klikom počnite ispočetka", + "ui.construction.point_hint": "Pomoćna prava: kliknite tačku kroz koju prolazi", + "ui.construction.reference_hint": "Pomoćna prava: izaberite pravu liniju, pa tačku; desnim klikom promijenite referencu", + "ui.construction.ribbon_label": "Pomoćne", "ui.context_menu": "Kontekstni meni", "ui.continuity_g0": "G0 pregib", "ui.continuity_g1": "G1 tangentno", @@ -3670,6 +4070,7 @@ "ui.design_properties.part_number": "Broj dijela", "ui.design_properties.project": "Projekat", "ui.design_properties.revision": "Revizija", + "ui.design_saved": "Sačuvano: {path}", "ui.diameter": "Prečnik", "ui.dimension": "Kota", "ui.direct_interaction": "Direktna interakcija", @@ -3720,6 +4121,14 @@ "ui.end_scale": "Krajnje skaliranje", "ui.entities": "Elementi", "ui.equal": "Jednakost", + "ui.equation_end": "Kraj", + "ui.equation_explicit": "Eksplicitno", + "ui.equation_form": "Oblik", + "ui.equation_note": "Varijabla je običan broj: sin(t) je uzima u radijanima. Rezultat bez jedinice je u jedinici dizajna.", + "ui.equation_parametric": "Parametarski", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalentni poluprečnik", "ui.expand": "Proširi", "ui.exportDxf": "Izvezi DXF…", @@ -3785,6 +4194,7 @@ "ui.heading_design": "DIZAJN", "ui.heading_draft": "NAGIB", "ui.heading_edit": "UREDI", + "ui.heading_equation_curve": "KRIVA IZ JEDNAČINE", "ui.heading_extrude": "EKSTRUDIRAJ", "ui.heading_fillet": "ZAOBLJENJE", "ui.heading_finish_editing": "ZAVRŠI UREĐIVANJE", @@ -3876,6 +4286,10 @@ "ui.import_ellipsis": "Uvezi…", "ui.infinite": "Beskonačno", "ui.insert": "Umetni", + "ui.insert_design.browse": "Pregledaj…", + "ui.insert_design.open_designs": "Otvoreni dizajni", + "ui.insert_design.saved_file": "{name} ima nesačuvane promjene, pa je umetnuta njegova sačuvana datoteka. Sačuvajte ga, a zatim pokrenite Ažuriraj veze da unesete promjene.", + "ui.insert_design.unsaved": "Nesačuvane promjene", "ui.inspect": "Pregledaj", "ui.intersect": "Presjek", "ui.isolate": "Izoluj", @@ -4019,12 +4433,29 @@ "ui.polygon": "Poligon", "ui.preferences": "Postavke", "ui.press_a_key": "pritisnite tipku…", + "ui.print_face_cleared": "{body}: površina za štampu uklonjena", + "ui.print_face_input": "Površina za štampu", + "ui.print_face_one_body": "Površina za štampu pripada jednom tijelu: pošaljite to tijelo samo da biste je promijenili", + "ui.print_face_pick": "Odaberite ravnu površinu punog tijela da postane površina za štampu", + "ui.print_face_set": "{body}: površina za štampu postavljena; Pošalji u slajser polaže tijelo na nju", "ui.profile": "Profil", "ui.profile_plane": "Ravan profila", "ui.profiles": "Profili", "ui.project": "Projektuj", "ui.project_face_edges_sketch": "Projektuj ivice lica u nove skice", "ui.properties": "Svojstva", + "ui.properties.copy_matrix": "Kopiraj matricu", + "ui.properties.first_moments": "Statički momenti", + "ui.properties.inertia": "Moment inercije", + "ui.properties.inertia_note": "Oko centra mase, u svjetskim osama (g·{unit}²)", + "ui.properties.inertia_unavailable": "Nije dostupno: mreža jednog tijela nije zatvorena.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mjerenje…", + "ui.properties.measuring_failed": "Mjerenje je zaustavljeno prije završetka.", + "ui.properties.principal_axis": "Glavna osa {number}", + "ui.properties.principal_moments": "Glavni momenti", "ui.pull_direction": "Smjer vađenja iz kalupa", "ui.quality": "Kvalitet", "ui.quantity": "Broj", @@ -4070,6 +4501,7 @@ "ui.revolve": "Rotiraj", "ui.ribbon_exit": "Izađi", "ui.ribbon_labels": "Prikaži tekst ispod ikona na traci", + "ui.ribbon_menus_skip_pinned": "Izostavi prikačene komande iz padajućih menija", "ui.ribbon_pin_footer": "Prikačene komande prikazuju se na traci. Zadržite pokazivač iznad reda da ga prikačite ili otkačite.", "ui.ribbon_workspace": "Radni prostor", "ui.right": "Desno", @@ -4087,6 +4519,7 @@ "ui.select": "Izaberi", "ui.selectOther": "Izaberi drugo…", "ui.selectTangentChain": "Izaberi tangentni lanac", + "ui.select_all": "Izaberi sve", "ui.selection": "Izbor", "ui.selection_modifiers": "Modifikatori izbora", "ui.selection_shift_add_only": "Shift+klik dodaje bez uklanjanja", @@ -4123,7 +4556,11 @@ "ui.sketch": "Skica", "ui.sketch_dimension": "Kota skice", "ui.sketch_grid": "Mreža skice", + "ui.sketch_intersect_geometry": "Geometrija", + "ui.sketch_intersect_projection_link": "Veza projekcije", + "ui.sketch_intersect_selection_filter": "Filter izbora", "ui.sketch_modifiers": "Modifikatori skice", + "ui.sketch_project_geometry": "Geometrija", "ui.sketch_scale": "Skaliranje skice", "ui.sketches": "Skice", "ui.slice": "Presijeci", @@ -4170,6 +4607,11 @@ "ui.symmetric": "Simetrično", "ui.symmetry": "Simetrija", "ui.system": "Sistem", + "ui.tab_assemble": "Sklopi", + "ui.tab_manage": "Upravljanje", + "ui.tab_sheet_metal": "Lim", + "ui.tab_view": "Prikaz", + "ui.tab_weldments": "Zavarene konstrukcije", "ui.tangent": "Tangentno", "ui.taper_angle": "Ugao suženja", "ui.tapped": "S navojem", @@ -4185,6 +4627,7 @@ "ui.tip_angle": "Ugao vrha", "ui.to_object": "Do objekta", "ui.tool_direction": "Smjer alata", + "ui.toolbar_tabs_hidden": "Sve kartice trake s alatima su skrivene. Kliknite + ili kliknite desnim dugmetom ovdje da prikažete jednu.", "ui.top": "Odozgo", "ui.top_diameter": "Gornji prečnik", "ui.top_radius": "Gornji poluprečnik", @@ -4222,6 +4665,8 @@ "ui.view_centre": "Centar prikaza", "ui.viewport_status_clear": "Isključi: {name}", "ui.viewport_status_clear_hint": "Isključi: {name} · Esc", + "ui.viewport_status_edges": "Ivice: {count}", + "ui.viewport_status_from": "iz smjera {direction}", "ui.viewport_status_key": "Prikaži legendu: {name}", "ui.viewport_status_minimum_radius": "Minimalni poluprečnik", "ui.viewport_status_pairs": "Parovi: {count}", @@ -4231,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Osjenčeno s ivicama", "ui.visual_style": "Vizuelni stil", "ui.visual_wireframe": "Žičani model", + "ui.wheel_zoom_speed": "Brzina zumiranja točkićem miša", "ui.width": "Širina", "ui.window_system": "Sistem prozora", "ui.window_system_auto": "Automatski", @@ -4261,7 +4707,9 @@ "update.button": "Ažuriraj", "update.button_version": "Ažuriraj na {version}", "update.check_automatically": "Automatski provjeravaj ažuriranja", + "update.check_for_updates": "Provjeri ažuriranja", "update.check_now": "Provjeri sada", + "update.check_unavailable": "Provjera ažuriranja nije dostupna za ovu instalaciju.", "update.checking": "Provjera…", "update.copy": "Kopiraj", "update.dmg_opened": "Slika diska je otvorena i provjerena: prevucite SolidCraft u Applications, zamijenite staru kopiju, a zatim ponovo pokrenite SolidCraft.", @@ -4284,6 +4732,9 @@ "update.prefs_managed": "Ovo je SolidCraft {version}, kojim upravlja {manager}. Provjera pita GitHub za najnovije izdanje i ne šalje ništa drugo; ažurirajte SolidCraft u {manager}.", "update.prefs_note": "Ovo je SolidCraft {version} ({kind}). Provjera pita GitHub za najnovije izdanje i ne šalje ništa drugo.", "update.prefs_web": "Ponovo učitajte stranicu da dobijete najnoviju web aplikaciju. Ako mreža nije dostupna ili se ažuriranje ne može preuzeti na vrijeme, otvara se sačuvana verzija; preuzimanje u toku nastavlja se za sljedeće ponovno učitavanje.", + "update.refresh_checking": "Provjera najnovijeg izdanja…", + "update.refresh_fallback": "Provjera novijeg izdanja nije uspjela; ažurira se na verziju {version}, pronađenu ranije.", + "update.refresh_selected": "Ažuriranje na {version}.", "update.release_page": "Otvori stranicu izdanja", "update.restart": "Ponovo pokreni sada", "update.restart_later": "Kasnije", diff --git a/assets/locales/cs.json b/assets/locales/cs.json index aa8f35ac88..44a8eb078d 100644 --- a/assets/locales/cs.json +++ b/assets/locales/cs.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.", "Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.", "Diameter": "Průměr", + "Did this assembly open the wrong way up?": "Otevřela se tato sestava špatně natočená?", + "Did this part open the wrong way up?": "Otevřel se tento díl špatně natočený?", "Dimension new shapes": "Kótovat nové tvary", "Direction": "Směr", "Discard": "Zahodit", @@ -204,6 +206,7 @@ "Edges": "Hrany", "Edit": "Upravit", "Edit Profile Sketch": "Upravit náčrt profilu", + "Edit shortcuts": "Upravit zkratky", "Enable rule": "Povolit pravidlo", "Enabled": "Povoleno", "End Radius": "Koncový poloměr", @@ -466,6 +469,7 @@ "Radius": "Poloměr", "Radius Fit": "Proložení poloměrem", "Ratio": "Poměr", + "Read-only list: shortcuts can't be changed here.": "Seznam jen pro čtení: zkratky zde nelze měnit.", "Reattach selected face": "Znovu připojit k vybrané ploše", "Recover": "Obnovit", "Recover unsaved design?": "Obnovit neuložený návrh?", @@ -599,6 +603,7 @@ "Square": "Čtverec", "Stand it up": "Postavit", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Postavit: otočení o čtvrt otáčky kolem osy X jako jeden prvek Posunutí, který lze vrátit.", + "Stand this assembly up?": "Postavit tuto sestavu?", "Stand this part up?": "Postavit tento díl?", "Start angle": "Počáteční úhel", "Start bulge": "Vypouklost začátku", @@ -649,6 +654,7 @@ "Third point": "Třetí bod", "This child is shared. Deleting it removes it from every copy of its parent.": "Tento potomek je sdílený. Smazáním jej odeberete ze všech kopií jeho rodiče.", "This child is shared. Position changes apply to every copy of its parent.": "Tento potomek je sdílený. Změny polohy se projeví ve všech kopiích jeho rodiče.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Tato komponenta je uvnitř podsestavy umístěné vícekrát: její zarovnání ji posune ve všech umístěních.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Tato plocha nemůže poskytnout trvalé připojení. Zvolte jinou plochu nebo výslovně použijte pevnou rovinu.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Tento obrázek ještě není přiložen na GitHubu. Zkopírujte ho nebo uložte a na GitHubu ho přiložte.", "Thread depth": "Hloubka závitu", @@ -769,6 +775,8 @@ "at the face": "u plochy", "bom.group_children": "Poddíly v řádku svého dílu", "browser.body_name": "Těleso{number}", + "browser.calibrate_click_a": "Klikněte do obrázku na bod A, nebo zadejte jeho souřadnice.", + "browser.calibrate_click_b": "Klikněte do obrázku na bod B, nebo zadejte jeho souřadnice.", "browser.calibrate_title": "Kalibrovat: {name}", "browser.capture_many": "Byly přesunuty komponenty ({count}), ale jejich polohy nejsou zachyceny.", "browser.capture_note": "Zachycení polohy ponechá novou polohu; vrácení obnoví původní.", @@ -790,6 +798,11 @@ "cam.roller": "Kladka", "cam.roller_pick": "Kladka (volitelné)", "cam.tip_pick": "Hrot (volitelné)", + "camera.shot_pitch": "Náklon", + "camera.shot_roll": "Natočení", + "camera.shot_yaw": "Stáčení", + "canvas.decal_place_hint": "Kliknutím na plochu umístíte obtisk", + "canvas.place_hint": "Kliknutím na rovinu nebo plochu umístíte plátno", "choose a profile": "vyberte profil", "choose the cam's and the follower's joints": "vyberte klouby vačky a zdvihátka", "choose the joints to couple": "vyberte klouby ke spřažení", @@ -797,12 +810,16 @@ "click sketch curves": "klikněte na křivky náčrtu", "cmd.appearance_assign": "Vzhled", "cmd.appearance_chain": "Řetězec vzhledů", + "cmd.appearance_copy": "Kopírovat styl", + "cmd.appearance_favorite": "Oblíbený styl", "cmd.appearance_library": "Knihovna vzhledů", "cmd.appearance_list": "Seznam vzhledů", "cmd.appearance_paint": "Nanést vzhled", + "cmd.appearance_paste": "Vložit styl", "cmd.appearance_pick_up": "Převzít vzhled", "cmd.appearance_replace": "Nahradit vzhled", "cmd.assembly_bom": "Kusovník", + "cmd.body.print_face": "Tisková plocha", "cmd.body_rename": "Přejmenovat těleso", "cmd.browser_collapse_all": "Sbalit vše", "cmd.browser_expand_all": "Rozbalit vše", @@ -810,6 +827,7 @@ "cmd.browser_move": "Přesunout položky prohlížeče", "cmd.browser_order": "Seřadit položky prohlížeče", "cmd.browser_rename_group": "Přejmenovat skupinu", + "cmd.browser_reparent": "Přesunout do komponenty", "cmd.browser_ungroup": "Zrušit skupinu", "cmd.browser_visibility": "Zobrazit/skrýt", "cmd.canvas_calibrate": "Kalibrovat plátno", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Vlastnosti dílu", "cmd.component_metadata_set": "Změnit vlastnosti dílu", "cmd.component_move_bodies": "Přesunout tělesa do komponenty", + "cmd.component_move_features": "Přesunout prvky do komponenty", "cmd.component_move_sketches": "Přesunout náčrty do komponenty", + "cmd.component_paste_from_design": "Vložit z jiného návrhu", "cmd.component_paste_new": "Vložit jako nové", "cmd.component_properties": "Fyzikální vlastnosti komponenty", "cmd.component_relink": "Znovu propojit zdroj", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Stopa pohybu", "cmd.motion_trace_edit": "Upravit stopu pohybu", "cmd.motion_trace_keep": "Ponechat stopu pohybu", + "cmd.occurrence_align": "Zarovnat výskyt", "cmd.occurrence_copy": "Vložit (instance)", "cmd.occurrence_drag": "Táhnout výskyt", "cmd.occurrence_ground": "Ukotvit", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Odebrat přiřazení povrchu", "cmd.render_library_targets": "Najít cíle renderovací knihovny", "cmd.render_lights_add": "Přidat světlo", + "cmd.render_lights_duplicate": "Duplikovat světlo renderu", + "cmd.render_lights_list": "Seznam světel renderu", + "cmd.render_lights_mute": "Ztlumit světlo", + "cmd.render_lights_remove": "Odebrat světlo renderu", + "cmd.render_lights_set": "Nastavit světlo renderu", "cmd.render_lights_solo": "Sólo světlo", "cmd.render_optics_bind": "Uložit optické přiřazení", "cmd.render_optics_unbind": "Odebrat optické přiřazení", "cmd.render_output_size": "Nastavit velikost výstupu vykreslení", "cmd.render_preflight": "Zkontrolovat požadavek na render", + "cmd.render_preflight_copy": "Kopírovat zprávu o kontrole renderu", "cmd.render_preset_apply": "Použít nativní studio", "cmd.render_preset_resolve": "Zkontrolovat nativní studio", "cmd.render_quality_prepare": "Zkontrolovat pevný rozpočet vzorkování", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Renderovat obrázek", "cmd.render_status": "Průběh renderu", "cmd.select_clear": "Zrušit výběr", + "cmd.settings_profile_apply": "Použít profil nastavení", + "cmd.settings_profile_delete": "Smazat profil nastavení", + "cmd.settings_profile_export": "Exportovat profil nastavení", + "cmd.settings_profile_import": "Importovat profil nastavení", + "cmd.settings_profile_list": "Profily nastavení", + "cmd.settings_profile_rename": "Přejmenovat profil nastavení", + "cmd.settings_profile_save": "Uložit profil nastavení", "cmd.sheet_convert": "Převést na plech", "cmd.sheet_export_bend_table": "Exportovat tabulku ohybů", "cmd.sheet_export_dxf": "Exportovat rozvin do DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Znovu ohnout", "cmd.sheet_rules": "Seznam pravidel pro plech", "cmd.sheet_unfold": "Rozvinout", + "cmd.sketch.construction_line": "Konstrukční přímka", + "cmd.sketch.construction_line.angle": "Konstrukční přímka pod úhlem", + "cmd.sketch.construction_line.bisector": "Osa úhlu", + "cmd.sketch.construction_line.horizontal": "Vodorovná konstrukční přímka", + "cmd.sketch.construction_line.parallel": "Rovnoběžná konstrukční přímka", + "cmd.sketch.construction_line.perpendicular": "Kolmá konstrukční přímka", + "cmd.sketch.construction_line.tangent_at": "Tečna v bodě křivky", + "cmd.sketch.construction_line.tangent_external": "Tečna vedená vnějším bodem", + "cmd.sketch.construction_line.tangent_two": "Společná tečna", + "cmd.sketch.construction_line.vertical": "Svislá konstrukční přímka", "cmd.sketch_arc3d": "3D oblouk", "cmd.sketch_arc_center_point": "Oblouk ze středu", "cmd.sketch_arc_tangent": "Tečný oblouk", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Velikost výkresu", "cmd.sketch_edit": "Upravit náčrt", "cmd.sketch_edit_text": "Upravit text", + "cmd.sketch_equation_curve": "Křivka z rovnice", + "cmd.sketch_equation_curve_edit": "Upravit křivku z rovnice", "cmd.sketch_export_dxf": "Uložit jako DXF", "cmd.sketch_finish_auto_constrain": "Dokončit s automatickými vazbami", "cmd.sketch_fit_curves_to_section": "Proložit křivky řezem sítě", @@ -1198,6 +1244,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_items": "Stavové položky pohledu", "cmd.view_ambient_occlusion": "Okolní zastínění", "cmd.view_angle_readout": "Úhly pohledu", "cmd.view_apply": "Obnovit pojmenovaný pohled", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Kolmo k výběru", "cmd.view_previous_tab": "Předchozí karta", "cmd.view_rename": "Přejmenovat pojmenovaný pohled", + "cmd.view_render_frame": "Vodítka rámce renderu", "cmd.view_right": "Pohled zprava", "cmd.view_save": "Nový pojmenovaný pohled", "cmd.view_shots_strip": "Pás záběrů", @@ -1229,10 +1277,14 @@ "color.copy": "Kliknutím zkopírujete hodnoty barvy", "color.edit": "Kliknutím upravíte barvu", "color.gamma": "Hodnoty barvy v gama prostoru 0–255", + "color.hex": "Hex", + "color.hex_help": "Zadejte #rrggbb nebo #rrggbbaa; šest číslic zachová neprůhlednost", + "color.hex_hint": "#rrggbb", "color.hue": "Odstín", "color.linear": "Hodnoty barvy v lineárním prostoru 0–1", "color.saturation_value": "Sytost a jas", "color.selected": "Vybraná barva", + "config.column_filter_empty": "Žádné odpovídající sloupce", "contact.components_count": "Počet komponent: {count}", "contact.enabled_label": "povoleno", "coupling.gear_1": "Kolo 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Dvojklikem na jiný řídicí kótu vložíte jeho název", "dialog.dimension_name": "Kóta: {name}", "dialog.edit_title": "UPRAVIT {title}", + "dialog.no_shown_body": "Není zobrazeno žádné těleso ke změně: zobrazte nějaké nebo zvolte Nové těleso", "dialog.select": "↖ Vybrat", "dialog.selected_count": "↖ Vybráno: {count}", "dialog.selection_guidance": "Vstup změníte jeho výběrem. Kliknutím na vybraný objekt jej odeberete; Ctrl/Cmd+klik výběr také přepíná. Zkontrolujte náhled a stiskněte OK.", @@ -1346,8 +1399,20 @@ "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.mit": "Licence MIT", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Vytvoří nekonečnou vodorovnou konstrukční přímku procházející bodem. Její vodorovná vazba platí i při pozdějších úpravách.", + "help.sketch.construction_line.parallel": "Vyberte přímou úsečku náčrtu a pak umístěte rovnoběžnou nekonečnou konstrukční přímku. Jinou referenci vyberete kliknutím pravým tlačítkem.", + "help.sketch.construction_line.perpendicular": "Vyberte přímou úsečku náčrtu a pak umístěte kolmou nekonečnou konstrukční přímku. Jinou referenci vyberete kliknutím pravým tlačítkem.", + "help.sketch.construction_line.tangent_at": "Vytvoří tečnu v bodě kružnice nebo kruhového oblouku, případně v koncovém bodě otevřeného splajnu nebo kuželosečky. Vyberte křivku a pak bod dotyku.", + "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.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.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.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.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í.", @@ -1374,6 +1439,8 @@ "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.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ě.", + "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í", "hole_presets.delete": "Smazat", "hole_presets.delete_prompt": "Smazat tuto osobní předvolbu? Existující otvory se nezmění.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.", "joint.rigid_group_one_component": "Těchto {count} těles patří do jedné komponenty (nebo přímo do návrhu): nejprve použijte {command}.", "joint.rigid_group_select_components": "Vyberte dvě nebo více komponent.", + "joint_move.blocked_hint": "Zkontrolujte typ kloubu (např. Pevný), ukotvení, limity kloubu a hodnoty řízené parametry.", + "joint_move.blocked_title": "Tento díl nelze posunout", + "joint_move.coarse": "Krok otáčení blízko osy (°)", + "joint_move.distance": "Vzdálenost od začátku", + "joint_move.done": "Hotovo", + "joint_move.fine": "Krok otáčení daleko od osy (°)", + "joint_move.fixed": "Tento díl nemá žádnou pohyblivou osu kloubu.", + "joint_move.help": "Posunout — táhněte za šipku nebo prstenec. Esc zruší aktuální tažení.", + "joint_move.legacy_hint": "Vypnutím použijete předchozí panel Posunout.", + "joint_move.precision": "Pro jemné otáčení táhněte mimo čárkované vodítko.", + "joint_move.rotation": "Otočení od začátku", + "joint_move.select": "Vyberte plochu nebo jeden díl sestavy a pak zvolte Posunout.", + "joint_move.settings": "Posun v sestavě", + "joint_move.step": "Krok", + "joint_move.use_handles": "Pro Posunout v sestavě používat úchyty pohybu kloubů", "keymap.command_id": "ID příkazu", "keymap.empty": "Žádné odpovídající příkazy", "keymap.use_anyway": "Přesto zde použít {key}", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+tažení levým tlačítkem otáčí, Alt+prostřední tlačítko posouvá, Alt+pravé tlačítko přibližuje; tažení prostředním tlačítkem také posouvá", "nav.openscad.help": "Tažení levým tlačítkem na prázdném místě otáčí, Shift+tažení levým vybírá obdélníkem, tažení geometrie náčrtu ji upravuje; tažení pravým nebo prostředním tlačítkem posouvá, Shift+tažení pravým nebo prostředním přibližuje", "nav.perspective.sketch": "Úpravy náčrtu používají ortografický pohled. Dokončením náčrtu přepnete na perspektivu.", + "nav.right_drag_orbits.help": "Vypnuto: tažení pravým tlačítkem kameru nemění a pravé tlačítko jen otevírá kontextovou nabídku. Volba přiřazení myši SolidWorks tuto možnost vypne.", + "nav.right_drag_orbits.label": "Tažení pravým tlačítkem otáčí", "nav.solidworks.help": "Tažení prostředním tlačítkem otáčí, Ctrl+prostřední posouvá, Shift+prostřední přibližuje, tažení pravým tlačítkem otáčí", "nav.tinkercad.help": "Tažení pravým tlačítkem otáčí, tažení prostředním tlačítkem nebo Shift+pravým posouvá", "nav.touchpad.help": "Podržením Shift a pohybem ukazatele posouváte; podržením Alt/Option a pohybem otáčíte. Tlačítko myši není potřeba. Svislé posouvání přibližuje.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Přizpůsobit navigační lištu…", "navbar.custom.design": "Návrh", "navbar.custom.duplicate": "Tato pozice už na liště je.", + "navbar.custom.edge": "Okraj", + "navbar.custom.edge_bottom": "Dole", + "navbar.custom.edge_top": "Nahoře", "navbar.custom.fade": "Zeslabit", "navbar.custom.full": "Pro přidání další pozice jednu odeberte.", "navbar.custom.hidden_design": "Skryto v Návrhu", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Tloušťka", "parts.dimension.width": "Šířka", "parts.family.bearing": "Kuličkové ložisko s hlubokou drážkou (obalový tvar)", + "parts.family.button_head_cap_screw": "Šroub s půlkulovou hlavou a vnitřním šestihranem", "parts.family.dowel_pin": "Válcový kolík (lícovaný)", "parts.family.hex_bolt": "Šroub se šestihrannou hlavou", "parts.family.hex_nut": "Šestihranná matice", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Vyhlazuje zubaté hrany.", "prefs.applies_on_restart": "Použije se po restartu", "prefs.assistant": "Asistent", + "prefs.auto_operation": "Automaticky vybírat operaci", + "prefs.auto_operation_note": "Okna prvků vyberou Sjednotit, Nové těleso nebo Vyjmout podle toho, čeho se nový materiál dotýká, dokud sami jednu nevyberete. Vypnuto: ponechají si operaci, se kterou se otevřou.", "prefs.axes": "Osy", "prefs.axis": "Osa", "prefs.bodies_folders": "Složky těles", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Dole uprostřed", "prefs.bottom_right": "Vpravo dole", "prefs.browser": "Prohlížeč", + "prefs.browser_eye_left": "Oko vedle šipky sbalení", + "prefs.browser_eye_left_note": "Řádky prohlížeče zobrazují oko pro zobrazení/skrytí vedle šipky sbalení jako dříve, místo ve sloupci na pravém konci řádků.", + "prefs.browser_reveal_on_hover": "Odkrýt při najetí myší", + "prefs.browser_show_note": "Zobrazeno ponechá prohlížeč na obrazovce. Odkrýt při najetí myší jej skryje, dokud ukazatel nenajede na jeho místo nebo na tenký jazýček u jeho levého okraje, a prohlížeč zůstane otevřený, dokud je ukazatel uvnitř něj nebo je otevřená některá z jeho nabídek.", + "prefs.browser_shown": "Zobrazeno", "prefs.built_in": "Vestavěný", "prefs.button_left": "Tažení levým tlačítkem", "prefs.button_middle": "Tažení prostředním tlačítkem", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Projeví se po restartování aplikace SolidCraft. Tuto volbu přepíše WGPU_ADAPTER_NAME.", "prefs.graphics_note": "Rychlost, nebo hladkost obrazu: každá volba ukazuje svůj účinek na ukázce.", "prefs.graphics_presets_note": "Předvolby nastaví níže uvedené volby najednou.", + "prefs.hide_constraint_glyphs_zoomed_out": "Skrýt symboly vazeb při oddálení", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Symboly vazeb zmizí z pohledu, když je náčrt oddálen natolik, že by ho zahlcovaly. Vypnuto: při každém zvětšení zůstávají ve stejné velikosti na obrazovce.", "prefs.history": "Historie", "prefs.history_origin_bodies": "Zobrazit počátek a tělesa nahoře", "prefs.history_origin_bodies_note": "Historie vlevo nebo pod prohlížečem začíná složkou Tělesa aktivní komponenty a počátkem, s ikonami oka a nabídkami jako v prohlížeči. Historie dole zůstává beze změny.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Kompaktní ukotví panely k okrajům okna. Průsvitnost prohlížeče je k dispozici v režimu Plovoucí. Stranu asistenta změníte na stránce Asistent.", "prefs.left": "Vlevo", "prefs.left_click": "Levé kliknutí", + "prefs.line_drag_draws_one": "Stisknutí a tažení nakreslí jednu úsečku", + "prefs.line_drag_draws_one_note": "V nástroji Úsečka nakreslí stisknutí, tažení a puštění jednu úsečku od místa stisknutí po místo puštění a tím kreslení skončí. Kliknutí stále kreslí řetězec. Vypnuto: tažení umístí bod tam, kde tlačítko pustíte, stejně jako kliknutí.", "prefs.linked_designs": "Propojené návrhy", "prefs.live_input": "Živý vstup", "prefs.live_preview": "Živý náhled", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "Soubory STEP a importu", "prefs.max_file_size_web": "Webová aplikace otevírá soubory STEP a importu do 512 MB a návrhy do 256 MB: karta prohlížeče větší soubory nepojme. V aplikaci pro počítač lze limity zvýšit.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Velké ikony", + "prefs.menu_list": "Seznam", + "prefs.menus": "Nabídky", "prefs.minutes": "minut", "prefs.minutes_suffix": " min", "prefs.mouse": "Myš a trackpad", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Umístění", "prefs.prefer_free_sketch_points": "Při překrytí bodů upřednostnit pohyblivé body náčrtu", "prefs.preview_choices": "Náhled vašich voleb", + "prefs.print_face_highlight": "Zvýraznění tiskové plochy", + "prefs.print_face_highlight_tip": "Jak výrazně se tisková plocha tělesa v pohledu obarví a obtáhne. 0 % nezobrazí žádné zvýraznění.", + "prefs.profile_active": "Aktivní profil", + "prefs.profile_applied": "Profil byl použit.", + "prefs.profile_changed": "Nastavení se od uložení profilu změnila. Uložit je do něj zapíše.", + "prefs.profile_deleted": "Profil byl smazán.", + "prefs.profile_export": "Exportovat…", + "prefs.profile_exported": "Profil byl exportován.", + "prefs.profile_imported": "Profil byl importován. Chcete-li se na něj přepnout, vyberte ho v části Aktivní profil.", + "prefs.profile_name_hint": "Název profilu, např. Notebook", + "prefs.profile_rename": "Přejmenovat", + "prefs.profile_renamed": "Profil byl přejmenován.", + "prefs.profile_save": "Uložit", + "prefs.profile_saved": "Aktuální nastavení byla uložena do profilu.", + "prefs.profile_skipped": "Profil byl použit; počet nastavení, která si ponechala aktuální hodnotu: {n} (viz protokol).", + "prefs.profiles": "Profily nastavení", + "prefs.profiles_note": "Zkratky, panel nástrojů, navigace, kruhová nabídka a nastavení rozhraní, uložené společně pod jedním názvem a přepínané najednou.", + "prefs.project_options_dialog": "Dialog možností promítnutí", + "prefs.project_options_dialog_note": "Zapnuto: před promítnutím zvolte Geometrie nebo Tělesa a Propojení promítnutí. Vypnuto: zachová se předchozí postup s přímým výběrem.", "prefs.quality_default": "výchozí", "prefs.quality_fastest": "nejrychlejší", "prefs.quality_slowest": "nejpomalejší", "prefs.quality_smoother": "hladší", + "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", + "prefs.ribbon_reveal": "Ukazovat výsledky hledání na pásu karet", + "prefs.ribbon_reveal_note": "Najetí na výsledek v Hledat příkazy ukáže, kde se příkaz na pásu karet nachází: jeho kartu, tlačítko nebo řádek v rozbalovací nabídce panelu. Příkaz spuštěný z hledání tam zabliká a běžící příkaz z rozbalovací nabídky označí svůj panel.", "prefs.right_click": "Pravé kliknutí", "prefs.saving": "Automatické ukládání a obnovení", "prefs.scale_note": "Také Ctrl + / Ctrl − v okně. Tlačítka mají při 100 % 28 px.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Žádná odpovídající nastavení", "prefs.select": "Vybrat", "prefs.select_one_item": "Vybrat jednu položku", + "prefs.select_other_hidden": "Vybírat skrytou geometrii pomocí Vybrat jiné", + "prefs.select_other_hidden_help": "Zapnuto: zahrne skrytá tělesa, náčrty a zakryté hrany v blízkosti ukazatele. Vypnuto: použije původní seznam viditelné geometrie pod ukazatelem.", "prefs.selecting": "Výběr", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift jen přidává; Ctrl / Cmd přepíná", "prefs.shift_add_note": "Zapnuto: Shift přidává, Ctrl / Cmd přepíná. Vypnuto (výchozí): obojí přepíná.", "prefs.shortcuts": "Zkratky", + "prefs.single_surface_analysis": "Používat jen jednu analýzu povrchu", + "prefs.single_surface_analysis_help": "Když je vypnuto, mohou stínování povrchu a Spojitost zůstat zapnuté současně. Zapnutím zůstane jen naposledy spuštěná nebo změněná analýza.", + "prefs.sketch_align_points": "Při kreslení zarovnávat s jinými body náčrtu", + "prefs.sketch_align_points_note": "Při kreslení se bod zarovná vodorovně s jedním bodem náčrtu a zároveň svisle s jiným, s tečkovaným vodítkem, a kliknutí zachová obě zarovnání. Vypnuto: jen s předchozím bodem, vždy v jedné ose.", "prefs.sketch_conflict_colour": "Konfliktní geometrie v barvě konfliktu", "prefs.sketch_conflict_colour_note": "Dokud jsou vazby náčrtu v konfliktu, vykreslují se v barvě konfliktu i křivky a body, které selhávající vazba drží, nejen její symbol nebo kóta. Vypnuto: ponechají si obvyklé barvy.", + "prefs.sketch_construction_lines": "Rozšířené nástroje konstrukčních přímek", "prefs.sketch_exit_view": "Po ukončení náčrtu", "prefs.sketch_exit_view.keep": "Ponechat aktuální pohled", "prefs.sketch_exit_view.previous": "Vrátit se k předchozímu pohledu", "prefs.sketch_exit_view.unless_rotated": "Vrátit, pokud nebyl pohled otočen", "prefs.sketch_exit_view_note": "Kam se pohled přesune po příkazu Dokončit náčrt, Zrušit nebo dvojitém Esc.", + "prefs.sketch_open_look_at": "Při otevření náčrtu natočit pohled k němu", + "prefs.sketch_open_look_at_note": "Vypnuto: vytvoření nebo úprava náčrtu ponechá pohled beze změny. Pohled kolmo natočí pohled k náčrtu, kdykoli ho chcete.", + "prefs.sketch_recent_snap": "Automaticky přichytávat k nedávným středům a středním bodům", + "prefs.sketch_recent_snap_note": "Při kreslení se kurzor přichytí tam, kde se kříží vodítka dvou posledních středů kružnic nebo středů rovných hran. Vypnuto: tato vodítka se jen zobrazují a kurzor se nepohne.", "prefs.sketch_selection": "Náčrt a výběr", "prefs.sketch_selection_heading": "Náčrt a výběr", "prefs.sketch_selection_note": "Jak se v náčrtu přichytává a jak kliknutí vybírá.", + "prefs.sketch_state_icons": "Ikony náčrtů v barvě jejich stavu", + "prefs.sketch_state_icons_note": "V Historii a v prohlížeči má ikona náčrtu barvu jeho stavu (nedourčený, plně zavazbený nebo v konfliktu) a její bublinová nápověda uvádí název stavu. Vypnuto: všechny náčrty mají stejnou ikonu.", "prefs.sketch_tab_always": "Vždy zobrazovat kartu Náčrt", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Zdroj", "prefs.speed": "Rychlost", "prefs.stand_up_note": "Po otevření souboru STEP nebo IGES s osou Z nahoru nabídnout otočení o 90° kolem osy X.", + "prefs.stand_up_unknown": "U souborů z neznámých programů", + "prefs.stand_up_unknown.ask": "Zeptat se v dialogu", + "prefs.stand_up_unknown.note": "Zobrazit poznámku", + "prefs.stand_up_unknown.off": "Nenabízet", + "prefs.stand_up_unknown_note": "Jak se nabídka zobrazí, když hlavička souboru neuvádí žádný program, o kterém je známo, že ukládá s osou Y nahoru. Poznámka se zobrazí jen tehdy, když soubor vypadá, že leží na zádech; volba Zeptat se v dialogu se ptá u každého takového souboru, jako dříve.", "prefs.status_bar": "Stavový řádek", "prefs.status_bar_note": "Tenký pruh na dolním okraji okna ukazuje, co se právě upravuje, stav otevřeného náčrtu svou barvou a jednotky návrhu; kliknutím na jednotky je změníte. Stav náčrtu se pak již nezobrazuje v rohu pohledu.", "prefs.status_done": "Hotovo", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Oznámení", "prefs.toggle_item": "Přidat položku, nebo ji znovu odebrat", "prefs.toolbar": "Panel nástrojů", + "prefs.toolbar_tabs": "Karty panelu nástrojů", "prefs.top": "Nahoře", + "prefs.trackpad_zoom_modifier": "Přibližovat posouváním dvěma prsty při podržení", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Podržením pouze této klávesy přibližujete svislým posouváním k ukazateli. Vypnuto ponechá stávající chování posouvání.", + "prefs.trackpad_zoom_modifier.off": "Vypnuto", "prefs.translucent": "Průsvitné", "prefs.under_browser": "Pod prohlížečem", "prefs.under_cursor": "Pod kurzorem", @@ -1928,13 +2081,24 @@ "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ě.", "prefs.viewport": "Zobrazovací okno", + "prefs.viewport_analysis_status": "Zobrazovat stav analýz nad pohledem", + "prefs.viewport_analysis_status_help": "Zobrazí aktivní analýzy nad pohledem, z každé položky otevře její legendu a klávesou Esc vypne nejnovější analýzu. Vypnutím obnovíte legendy v rozích a předchozí chování klávesy Esc.", + "prefs.viewport_background": "Pozadí", + "prefs.viewport_background_colour": "Barva", + "prefs.viewport_background_theme": "Přechod motivu", "prefs.wheel": "Kolečko", "prefs.wheel_trackpad": "Kolečko a trackpad", "prefs.workspace": "Pracovní prostředí", + "prefs.zoom_stop_at_model": "Zastavit přiblížení u modelu", + "prefs.zoom_stop_at_model_tip": "3D myš a aplikace zařízení přibližují k povrchu uprostřed pohledu; přiblížení tažením drží bod, kde tažení začalo. Vypnuto: přiblížení může projít modelem. Kolečko a sevření prstů se řídí zvlášť volbou Přibližovat ke kurzoru", "prefs.zoom_toward_cursor": "Přiblížení směrem ke kurzoru", "properties.title": "Vlastnosti: {name}", "recovery.days_ago": "před {count} dny", "recovery.detail": "{file} · prvků: {features} · automaticky uloženo {when}", + "recovery.discard_all": "Zahodit vše", + "recovery.discard_all_detail": "Tuto akci nelze vrátit zpět.", + "recovery.discard_all_title": "Zahodit automatická uložení: {count}?", + "recovery.discard_all_title_one": "Zahodit 1 automatické uložení?", "recovery.hours_ago": "před {count} h", "recovery.just_now": "právě teď", "recovery.min_ago": "před {count} min", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Váha", "render.appearance_physics_separate": "Vizuální vzhled nepřiřazuje hustotu, tepelné údaje ani jiné inženýrské vlastnosti.", "render.appearances": "Vzhledy", + "render.assign_help": "Kliknutím použijete na výběr; tažením zobrazíte náhled a puštěním jej použijete, nebo kliknutím bez výběru zahájíte nanášení.", "render.availability_native": "Nativní kompozice; před použitím zkontrolujte", "render.availability_projection": "Podporované promítnutí; viz rozsah a vynechané části", "render.availability_reference": "Referenční data; nejde o automatické přiřazení", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Lineární práh jasů", "render.browse_capabilities": "Zobrazit úplný katalog možností", "render.browse_environments": "Procházet původní prostředí HDR", + "render.browse_looks": "Procházet…", "render.browse_native_scenes": "Procházet nativní studia", "render.browse_physical": "Zobrazit fyzikální reference", "render.browse_quality": "Zobrazit specifikace kvality", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Průhlednost: pouze neprůhledné povrchy; alfa pozadí je podporována.", "render.check_sorted_alpha": "Průhlednost: aproximace seřazenou alfou.", "render.check_transmission": "Průhlednost: fyzikální prostup světla.", + "render.children.absolute_floor": "Absolutní práh", + "render.children.adaptive": "Adaptivní", + "render.children.add_look_reason": "Chcete-li upravit zapnutí a sílu stylu, přidejte styl.", + "render.children.all_shots": "Všechny záběry", + "render.children.bloom": "Záře", + "render.children.check": "Kontrola", + "render.children.check_now": "Zkontrolovat nyní", + "render.children.chromatic": "Chromatický posun", + "render.children.compact": "Kompaktní", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopírovat", + "render.children.copy_job": "Kopírovat JSON úlohy", + "render.children.copy_request": "Kopírovat JSON požadavku", + "render.children.coverage_error": "Chyba pokrytí", + "render.children.cpu": "Trasování paprsků CPU", + "render.children.denoise": "Potlačit šum v hotovém obrazu", + "render.children.depth": "Hloubka cesty", + "render.children.distortion": "Zkreslení", + "render.children.edge_samples": "Vzorky hran", + "render.children.effects_banner": "Tyto efekty vyžadují trasování cest GPU. Otevření této části nikdy nezmění metodu.", + "render.children.enable_adaptive": "Chcete-li upravit adaptivní meze, zapněte nezávislé pilotní vzorky.", + "render.children.enable_bloom": "Chcete-li upravit sílu, práh a poloměr záře, zapněte záři.", + "render.children.finishing": "Finální úpravy", + "render.children.flare": "Odlesk", + "render.children.fresnel": "Fresnelovy vrstvy", + "render.children.ghosts": "Prstence duchů", + "render.children.glass": "Transport světla ve skle", + "render.children.go_to": "Přejít na: {editor}", + "render.children.grain": "Zrno", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Zachovat surový lineární master", + "render.children.last_result": "Poslední: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efekty objektivu", + "render.children.look_summary": "{tone} · stylů: {count}", + "render.children.looks": "Kreativní styly", + "render.children.memory": "Paměť", + "render.children.method": "Metoda", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Vyžaduje trasování paprsků CPU. Otevření této části nikdy nezmění metodu.", + "render.children.nested": "Ověřená vnořená dielektrika", + "render.children.not_checked": "Nezkontrolováno", + "render.children.path": "Trasování cest", + "render.children.pilots": "Pilotní vzorky", + "render.children.preview": "Data náhledu", + "render.children.progressive_reason": "Chcete-li upravit počet vzorků a semeno, zvolte Progresivní.", + "render.children.radius": "Poloměr", + "render.children.reflect_depth": "Hloubka odrazů", + "render.children.reflect_samples": "Vzorky odrazu", + "render.children.regrade": "Nová korekce barev", + "render.children.relative_error": "Relativní chyba", + "render.children.render": "Renderovat", + "render.children.render_shot": "Renderovat záběr", + "render.children.renderer": "Vykreslovač", + "render.children.renderers": "Vykreslovače", + "render.children.request": "Tento požadavek", + "render.children.reset_finishing": "Obnovit finální úpravy", + "render.children.review_quality": "Zkontrolovat specifikaci kvality…", + "render.children.rgb_gain": "Zesílení RGB", + "render.children.samples": "Vzorky", + "render.children.sampling": "Vzorkování", + "render.children.saturation": "Sytost", + "render.children.scene_summary": "těles: {bodies} · trojúhelníků: {triangles}", + "render.children.seed": "Semeno", + "render.children.sharpening": "Doostření", + "render.children.single_image": "Jeden obrázek", + "render.children.soft_focus": "Měkké ostření", + "render.children.streak": "Pruh", + "render.children.strength": "Síla", + "render.children.studio": "Studio", + "render.children.threshold": "Práh", + "render.children.tone": "Tón", + "render.children.under_hood": "Pod kapotou", + "render.children.vignette": "Vinětace", + "render.children.workstation": "Pracovní stanice", + "render.choose_look": "Nejprve vyberte styl ve Vzhledech", "render.chromatic_offset": "Chromatický posun hran", "render.clear_stage": "Odebrat uloženou scénografii", "render.copy_request": "Kopírovat JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Síla odlesku v jasných partiích", "render.flare_threshold": "Práh odlesku ve scénově lineárním prostoru", "render.fold_browser": "Sbalit prohlížeč", + "render.frame_aspect": "Poměr stran", + "render.frame_centre_mark": "Značka středu", + "render.frame_copy_limit": "Není k dispozici žádný volný název pro kopii záběru", + "render.frame_copy_name": "{name} kopie {number}", + "render.frame_custom": "Vlastní", + "render.frame_distance_unit": " × velikost", + "render.frame_duplicate": "Duplikovat", + "render.frame_exposure": "Expozice", + "render.frame_fov": "Zorný úhel", + "render.frame_guides": "Vodítka", + "render.frame_invalid_aspect": "Neplatná předvolba poměru stran", + "render.frame_lens": "Zorný úhel {fov}° · ≈ {mm} mm", + "render.frame_name": "Název", + "render.frame_output_size": "Velikost výstupu", + "render.frame_photo": "3:2 · Fotografie", + "render.frame_portrait": "4:5 · Portrét", "render.frame_presets": "Předvolby rámce", "render.frame_presets_pending": "Přijde s editorem záběru", + "render.frame_projection": "Projekce", + "render.frame_render_shot": "Renderovat tento záběr", + "render.frame_rule_thirds": "Pravidlo třetin", + "render.frame_safe_area": "Bezpečná oblast", + "render.frame_shot_title": "Záběr · {name}", + "render.frame_square": "1:1 · Čtverec", + "render.frame_tall": "9:16 · Na výšku", + "render.frame_thirds": "Třetiny", + "render.frame_wide": "16:9 · Širokoúhlý", "render.gpu_depth": "Maximální hloubka cesty", "render.gpu_options_help": "Tyto volby platí pro tuto úlohu. Expozice vychází z uloženého nastavení.", "render.gpu_path_failed": "Trasování cest GPU není dostupné", @@ -2360,6 +2627,7 @@ "render.heading_results": "VÝSLEDKY", "render.heading_shots": "ZÁBĚRY", "render.hide_shots_label": "Skrýt pás záběrů", + "render.hover_look": "{object} · {look} · kliknutím vyberete", "render.image_advanced": "Pokročilé nastavení GPU", "render.image_cancelled": "Render zrušen", "render.image_cancelling": "Rušení", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Použít jednotnou aproximaci bez prostorových nebo pokročilých efektů", "render.library_usable": "Spustitelná promítnutí", "render.library_users": "Použito na", + "render.light_aim": "Mířit na", "render.light_anchor_pending": "Změna souřadnicového systému světla zatím není podporována.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Chcete-li upravovat světla, zvolte trasování paprsků CPU nebo výslovné studio GPU. Světla starších studií GPU zůstávají jen pro čtení.", + "render.light_behaviour": "CHOVÁNÍ", + "render.light_default_fill": "Doplňkové", + "render.light_default_key": "Hlavní", + "render.light_default_number": "Světlo {number}", + "render.light_default_rim": "Obrysové", + "render.light_delete": "Smazat", + "render.light_distance": "Vzdálenost", + "render.light_duplicate": "Duplikovat", + "render.light_elevation": "Elevace", + "render.light_gpu_punctual": "Bodová světla a reflektory jsou k dispozici s trasováním paprsků CPU.", + "render.light_hard_shadow": "Toto světlo má ostré stíny; měkkost zářiče není k dispozici.", + "render.light_intensity": "Intenzita", + "render.light_missing": "Toto světlo už neexistuje. Vyberte světlo v prohlížeči.", + "render.light_place_highlight": "Umístit podle odlesku", + "render.light_place_pending": "Umístění podle odlesku vyžaduje výběr světla v pohledu a zatím není podporováno.", + "render.light_position": "POLOHA", + "render.light_preview_help": "Živý pohled je bez stínů; plošná světla používají aproximaci vzorkem ve středu. Render používá úplný model světla.", + "render.light_product_centre": "Střed produktu", + "render.light_size": "Velikost", + "render.light_softness": "Měkkost", + "render.light_temperature": "Barevná teplota", + "render.light_temperature_help": "Přibližný odstín záření černého tělesa; ukládá se jako lineární RGB.", + "render.light_type": "Typ", + "render.light_type_area": "Plošné", + "render.light_type_point": "Bodové", + "render.light_type_spot": "Reflektor", + "render.light_type_sun": "Slunce", + "render.light_view_only": "Sólo a ztlumení ovlivňují tento pohled a jeho rendery; nikdy se neukládají do návrhu.", + "render.light_world_origin": "Počátek světa", + "render.lighting_aim_point": "Cílový bod", + "render.lighting_apply": "Použít předvolbu", + "render.lighting_area_samples": "Vzorky plošného světla", + "render.lighting_budget": "Rozpočet", + "render.lighting_budget_help": "Počty jsou jen pro čtení. Změníte je přidáním, duplikováním nebo smazáním světel; meze určuje metoda.", + "render.lighting_colour": "Barva", + "render.lighting_consent": "Nahradit směrová světla touto předvolbou", + "render.lighting_custom": "Vlastní světla", + "render.lighting_custom_help": "Volbou Vlastní se předvolba změní na upravitelná světla, každé na vlastním řádku stromu.", + "render.lighting_direction": "Směr X Y Z", + "render.lighting_direction_help": "Nebo zadejte směr ke světlu (X Y Z).", + "render.lighting_distance_unit": " × velikost", + "render.lighting_edge_u": "Vektor strany U", + "render.lighting_edge_v": "Vektor strany V", + "render.lighting_falloff": "Útlum", + "render.lighting_falloff_help": "Intenzita je uvedena pro referenční vzdálenost.", + "render.lighting_height": "Výška V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Ztlumit toto světlo", + "render.lighting_point": "Bod", + "render.lighting_preset": "Předvolba studia", + "render.lighting_preset_cpu": "Směrové předvolby studia vyžadují trasování paprsků CPU. Osvětlení GPU používá výslovně zadaná světla Slunce a Plošné.", + "render.lighting_preset_row": "Předvolba", + "render.lighting_radiance": "Zář", + "render.lighting_reference": "Reference", + "render.lighting_review_help": "Nahradí seznam směrových světel. Zachová bodová a plošná světla, prostředí, stíny, kameru a všechna ostatní nastavení záběru.", + "render.lighting_review_title": "Zkontrolovat předvolbu osvětlení", + "render.lighting_samples_cpu": "Vzorky plošného světla platí pro trasování paprsků CPU.", + "render.lighting_samples_need_area": "Chcete-li nastavit vzorky plošného světla, přidejte plošné světlo CPU.", + "render.lighting_samples_reason": "Vzorky plošného světla musí být 1, 4, 16 nebo 64.", + "render.lighting_shadow_cpu": "Přepínač stínů vyžaduje trasování paprsků CPU. Trasování cest GPU vrhá stíny vždy.", + "render.lighting_shadows": "Vrhat stíny", + "render.lighting_shadows_section": "Stíny a vzorkování", + "render.lighting_shape": "Tvar", + "render.lighting_solo": "Sólo pro toto světlo", + "render.lighting_source": "Zdroj", + "render.lighting_source_section": "Zdroj světla", + "render.lighting_spot_preserved": "Toto bodové světlo si ponechá uložený kužel reflektoru, dokud se nezmění jeho typ.", + "render.lighting_sun_distance": "Směr slunce nezávisí na vzdálenosti ani na cílovém bodě.", + "render.lighting_unsupported": "nepodporováno", + "render.lighting_used": "{used} z {limit}", + "render.lighting_width": "Šířka U", + "render.lighting_world_position": "Svět X Y Z", "render.lights_fixed_to": "Světla pevně vázána na", "render.linear_help": "Uchovává 32bitový master RGBA s plovoucí desetinnou čárkou před odstraněním šumu, expozicí, mapováním tónů, filtry a pozadím. Lineární sRGB/D65; premultiplikováno pokrytím. Dodatečná paměť se povoluje výslovně. Bez komprese a bez dalších průchodů.", "render.linear_retain": "Zachovat surový lineární EXR", "render.linear_unavailable": "Vybraný dokončený snímek nemá zachovaný EXR. Zapněte uchování před renderováním; starší PNG nemůže obnovit surovou radianci.", "render.look_add": "Přidat barevný styl", + "render.look_applied": "Použito: {name}", "render.look_bleach": "Inspirováno technikou bleach bypass", "render.look_cool": "Studený", "render.look_cyanotype": "Inspirováno kyanotypií", "render.look_faded": "Vybledlý tisk", + "render.look_material": "Materiál", "render.look_mono": "Monochromatický", + "render.look_pasted": "Styl vložen", "render.look_sepia": "Sépie", "render.look_strength": "Síla stylu", "render.look_teal_orange": "Modrozelená / oranžová", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Rozšířené", "render.output_backend_ready": "Připraveno", "render.output_check": "Kontrola", + "render.output_check_failed": "Kontrola · chyba", "render.output_check_help": "Spusťte Kontrolu a požadavek si prohlédněte bez zahájení vykreslování.", "render.output_check_summary": "Kontrola · Poznámky: {count}", "render.output_copy": "Kopírovat", + "render.output_copy_report": "Kopírovat zprávu", "render.output_destination": "Cíl", "render.output_destination_help": "Cílový soubor vyberte při exportu hotového obrázku.", "render.output_dimensions": "Velikost", @@ -2604,6 +2952,7 @@ "render.output_fit": "Přizpůsobit", "render.output_format": "Formát", "render.output_format_status": "PNG je přesný dokončený obraz. Volitelné GPU EXR uchovává lineární FLOAT32 radianci před filtry. Export nikdy nenahrazuje výsledek náhledem nebo jinou úlohou.", + "render.output_grading": "Korekce barev", "render.output_image": "Obrázek", "render.output_largest": "max. {width} × {height}", "render.output_lock_ratio": "Zamknout poměr stran", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "čeká na specifikaci kvality", "render.output_ratio_refused": "Svázaný rozměr překračuje limit velikosti této metody. Předchozí velikost zůstává zachována; odemkněte poměr nebo zvolte menší hodnotu.", "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_ok": "Velikost je v pořádku · {width} × {height} px", "render.output_swap_orientation": "Prohodit šířku a výšku", "render.output_tools_inspector": "Velikost a formát upravte v inspektoru Výstup.", @@ -2629,6 +2980,7 @@ "render.page_output": "Výstup", "render.page_scene": "Scéna", "render.paint_label": "Nátěr", + "render.painting_with": "Nanáší se: {name}", "render.passive_stage": "Podlaha a pasivní scénografie", "render.passive_stage_help": "Konečné pasivní panely patří pouze k vykresleným obrazům. Nepřidávají žádná tělesa CAD ani hmotnost. Umístění je po použití pevné; při průniku s hranicemi modelu se použití odmítne. Vykreslování na CPU neposkytuje difuzní odražené osvětlení.", "render.perspective": "Perspektivní kamera", @@ -2650,6 +3002,7 @@ "render.pick_help": "Vyberte těleso, plochu nebo komponentu. Kliknutím do prázdného místa výběr zrušíte; Shift nebo Ctrl přidává či přepíná. Zvýraznění se zobrazí jen v živém zobrazení produktu.", "render.pick_label": "Vybrat", "render.pick_up_label": "Převzít", + "render.pick_up_prompt": "Kliknutím na objekt převezmete jeho styl", "render.pick_waiting": "Výběr v zobrazení produktu zatím není podporován.", "render.post_contrast": "Kontrast", "render.post_gain_b": "Zesílení modré", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Radiální zkreslení", "render.radial_fit": "Přizpůsobit produkt", "render.radial_save": "Uložit záběr", + "render.recent_looks": "Nedávné styly", "render.regrade_action": "Znovu korigovat barvy surového masteru (bez odšumění)", "render.regrade_help": "Vytvoří samostatný PNG z vybraného dokončeného surového masteru pomocí těchto prezentačních nastavení. Nesleduje paprsky, nemění zachycenou velikost, kameru ani světla a nepřepisuje soubor EXR. Odšumění se znovu nepoužije: surové mastery neuchovávají jeho pomocné vrstvy. Odvozený obraz lze dál exportovat týmiž ovládacími prvky úlohy.", "render.regrade_label": "Nová korekce barev", "render.reload_settings": "Znovu načíst uložené nastavení", + "render.remove_override": "Odebrat přepsání", + "render.replace_all": "Nahradit vše", "render.replace_ground": "Potvrdit nahrazení stávající scénografie", "render.replace_label": "Nahradit", "render.replace_saved_surface": "Nahradit stávající uložené přiřazení povrchu pro tento cíl", + "render.replace_with": "Nahradit za", "render.restore_camera": "Obnovit uloženou kameru", "render.result_actual": "100%", "render.result_actual_help": "Jeden pixel obrázku na jeden pixel obrazovky.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Odebrat uložené přiřazení", "render.saved_surfaces": "Uložená přiřazení povrchů", "render.saved_surfaces_help": "Připnuté vestavěné mapy sledují vybraný výskyt v lokálních milimetrech. Nahrazený nebo chybějící cíl je chyba, nikdy automatické přeřazení.", + "render.scene_all_shots": "Všechny záběry ({count})", "render.scene_apply": "Použít zkontrolované studiové nastavení", + "render.scene_batch_reason": "Všechny záběry vyžadují frontu renderování uložených záběrů, která zatím není k dispozici.", + "render.scene_check_now": "Zkontrolovat nyní", + "render.scene_check_passed": "Kontrola prošla · poznámek: {notes}", + "render.scene_check_stale": "Kontrola je zastaralá · zkontrolujte znovu", + "render.scene_check_unchecked": "Zatím nezkontrolováno · 0 poznámek", "render.scene_consent": "Potvrdit nahrazení osvětlení, prostředí, expozice a scénografie", + "render.scene_light_summary": "{environment} · světel: {lights}", + "render.scene_look_summary": "stylů: {looks} · přepsání: {overrides}", + "render.scene_open_check": "Otevřít kontrolu", "render.scene_preserved_fields": "Kamera, vzorkování, rozměry, optická přiřazení, geometrie CAD a technické materiály zůstanou zachovány.", "render.scene_preserves_design": "Vytvoří osvětlení a pasivní scénografii pro viditelný objekt. Geometrie, technické materiály a kamera zůstávají oddělené.", + "render.scene_ready": "Připraveno k vykreslení", + "render.scene_reminder": "Scéna zachovává návrh. Geometrie, inženýrské vlastnosti ani CAD se z renderu nikdy nemění.", + "render.scene_reminders": "Připomínky", + "render.scene_renderer_ready": "{renderer}: připraveno", "render.scene_replaces": "Nahrazuje", "render.scene_review": "Vyřešit a zkontrolovat", "render.scene_review_help": "Před použitím zkontrolujte celou nativní kompozici. Nenavázané pokročilé specifikace SCENE se nedosazují.", "render.scene_scope_details": "Rozsah kompozice", + "render.scene_stages": "Fáze", "render.search_appearances": "Hledat mezi {count} vzhledy", "render.setup_legacy_environment": "Toto starší nastavení používá zastaralé natočení nebo intenzitu prostředí. Tyto hodnoty zůstávají zachovány; před uložením úplné úlohy obrazu je výslovně převeďte.", "render.setup_name": "Název nastavení", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "KAMERA RENDERU", "render.shell_tab_lighting": "OSVĚTLENÍ", "render.shell_tab_scene": "SCÉNA RENDERU", + "render.shot_aperture": "Clona", + "render.shot_distance": "Vzdálenost", + "render.shot_dof": "Hloubka ostrosti", "render.shot_duplicate": "Duplikovat uložený záběr", + "render.shot_edit_output": "Upravit ve Výstupu", + "render.shot_exposure_reason": "Expozice vyžaduje trasování paprsků CPU nebo trasování cest GPU.", + "render.shot_exposure_section": "Expozice", + "render.shot_focus_distance": "Zaostřovací vzdálenost", + "render.shot_fov_reason": "Zorný úhel vyžaduje perspektivní projekci.", + "render.shot_frame_subject": "Zarámovat objekt", + "render.shot_invalid_camera": "Hodnoty kamery musí být konečné a v povoleném rozsahu.", + "render.shot_lens": "Objektiv", + "render.shot_lens_enable_reason": "Chcete-li upravit clonu a zaostřovací vzdálenost, zapněte hloubku ostrosti.", + "render.shot_lens_gpu_reason": "Hloubka ostrosti vyžaduje trasování cest GPU.", + "render.shot_lens_perspective_reason": "Hloubka ostrosti vyžaduje perspektivní projekci.", + "render.shot_look_selection": "Zamířit na výběr", "render.shot_manager": "Uložené záběry", "render.shot_new_name": "Název nového záběru", "render.shot_remove": "Odebrat uložený záběr", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Odebrat uložený záběr?", "render.shot_rename": "Přejmenovat uložený záběr", "render.shot_saved_only": "Nejprve uložte aktuální koncept. Duplikace a přejmenování zachovají přesné uložené volby a odkazy na zdroje.", + "render.shot_selection_reason": "Vyberte těleso nebo plochu, na kterou se má zamířit.", + "render.shot_size": "Velikost", + "render.shot_target": "Cíl", "render.shots_camera_changed": "Kamera se od uložení tohoto záběru změnila", "render.shots_name_help": "Zadejte jedinečný název aktuálního záběru.", "render.shots_no_result": "Zatím žádný render", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "zatím nepodporováno: výběr produktu a odebírání vzorků vzhledu", "render.wait_replace": "zatím nepodporováno: nahrazení všech použití vzhledu", "render.wait_solo": "zatím nepodporováno: viditelnost světel a ovládání sóla", + "render.wear_applies_to": "Platí pro", + "render.wear_layer_count": "Vrstev: {count}", + "render.wear_layer_one": "{count} vrstva", + "render.wear_layers": "Vrstvy", + "render.wear_no_layers": "Žádné vrstvy", + "render.wear_selected_layer": "Vybraná vrstva", + "render.wear_title": "Opotřebení", "sample.bracket.about": "Základní příruba s okrajovými přírubami a lemem podle pravidla 2 mm", "sample.bracket.name": "Plechový držák", "sample.enclosure.about": "Skořepinová krabička s osazením, nálitky pro šrouby, opěrkou a zacvakávacím spojem", @@ -3030,6 +3426,7 @@ "select an axis": "vyberte osu", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "nejprve vyberte osu, přímou nebo kruhovou hranu, nebo plochu válce, kužele či anuloidu", "select bodies first": "nejprve vyberte tělesa", + "select components first": "nejprve vyberte komponenty", "select corner points, or the two lines of one corner": "vyberte rohové body nebo dvě úsečky jednoho rohu", "select one corner point, or the two lines of one corner": "vyberte jeden rohový bod nebo dvě úsečky jednoho rohu", "select sheet edges first": "nejprve vyberte hrany plechu", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Dvojitým kliknutím na komponentu ji aktivujete, propojenou komponentu upravíte na místě", "settings.frame_rate_details_note": "Přidá graf časů posledních snímků s vykreslenými trojúhelníky, histogram časů snímků a to, co vykreslila GPU. Kliknutím na snímkovou frekvenci v pohledu je zobrazíte nebo skryjete.", "settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu.", + "settings.insert_lists_open_designs": "Vložit návrh nabízí nejprve návrhy otevřené na jiných kartách", "settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z", "settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.", "settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Odepnout", "sketch is missing": "náčrt chybí", "sketch.degrees_of_freedom": "Stupně volnosti: {count}", + "sketch.dimension_added_driven": "Náčrt už tuto hodnotu určuje, proto byla kóta přidána jako řízená (referenční)", + "sketch.dimension_applied": "Kóta použita", + "sketch.dimension_applied_defined": "Kóta použita; definován parametr `{name}`", + "sketch.dimension_applied_renamed": "Kóta použita; parametr pojmenován `{name}`", "sketch.dof_left": "Zbývající stupně volnosti: {count}", "sketch.dof_left_one": "Zbývá {count} stupeň volnosti", "sketch.draw_curves": "Kreslit jako {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Použít soubor písma {path}", "solid": "těleso", "status.editing": "Upravuje se {name}", + "status.navigation": "Přiřazení myši: {mapping}", + "status.navigation_tip": "Jak myš pohybuje pohledem. Kliknutím zvolíte jiné přiřazení", "status.under_constrained": "Nedourčeno (stupňů volnosti: {count})", "status.under_constrained_one": "Nedourčeno ({count} stupeň volnosti)", "status.units_tip": "Jednotky návrhu. Kliknutím je změníte", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Ponechá jejich prvky a vnořené složky v historii", "timeline.delete_folders_with_features_tip": "Smaže složky se všemi prvky v nich a jejich vnořenými složkami", "timeline.delete_many": "Smazat vybrané prvky: {count}", + "timeline.edited_rolled_back": "Prvek {name} se změnil, ale leží za značkou historie, takže se nesestavuje: chcete-li vidět výsledek, přesuňte značku za něj", "timeline.feature_error": "Chyba: {message}", "timeline.feature_hint": "Dvojklikem upravíte · přetažením změníte pořadí · pravým tlačítkem zobrazíte další možnosti", + "timeline.feature_rolled_back": "Vráceno zpět: za značkou historie, proto se nesestavuje", "timeline.feature_warning": "Upozornění: {message}", "timeline.group": "Nová složka prvků", "timeline.move_many": "Přesunout položky časové osy", "timeline.move_to_group": "Přesunout prvky do složky", "timeline.rename_group": "Přejmenovat složku prvků", "timeline.reorder_many": "Změnit pořadí prvků", + "timeline.suppress_features": "Potlačit prvky", "timeline.suppress_folders_tip": "Potlačí nebo zruší potlačení všech prvků ve složkách a v jejich vnořených složkách", "timeline.ungroup": "Odebrat složky prvků", + "timeline.unsuppress_features": "Zrušit potlačení prvků", "to the face": "k ploše", "to the next face": "k další ploše", "toolbar.customize_note": "Karta {tab}. Zaškrtnutím příkazu přidáte jeho tlačítko na panel nástrojů. Můžete také přetáhnout řádek z rozbalovací nabídky panelu na panel nástrojů, odstranit tlačítko přetažením mimo panel nástrojů nebo použít připínáček na řádku rozbalovací nabídky.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Kalibrace 3D myši opraví prohozené nebo obrácené osy a nastaví její rychlost a pocit z ovládání.", "ui.3d_mouse_offer_never": "Už nezobrazovat", "ui.3d_mouse_offer_swap": "Přepnout na Hidraw (přímý ovladač)", + "ui.3d_mouse_orbit": "Otáčení", + "ui.3d_mouse_orbit_classic": "Klasické", + "ui.3d_mouse_orbit_tip": "Sledované: 3D myš otáčí pohled kolem té části modelu, která je na začátku otáčení uprostřed pohledu, označené slabou kuličkou. Klasické: kolem středu pohledu", + "ui.3d_mouse_orbit_traced": "Sledované", "ui.3d_mouse_reading": "Hodnoty", "ui.3d_mouse_reset_axes": "Obnovit přiřazení os", "ui.3d_mouse_reset_view": "Obnovit pohled", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.", "ui.3d_mouse_switch_cap": "Kalibrovat jako návrat do středu", "ui.3d_mouse_switch_trackball": "Kalibrovat jako trackball", + "ui.3d_mouse_use_direct": "Použít přímý ovladač", + "ui.3d_mouse_use_direct_tip": "Číst 3D myš přímo (hidraw) místo přes spacenavd.", "ui.3d_mouse_view_axis": "Osa pohledu", "ui.3d_mouse_view_off": "Vypnuto", "ui.3d_mouse_view_off_tip": "Profil ovladače 3D myši tuto osu vypíná, takže její pohyb s pohledem nehýbe. Když tu vyberete pohyb jiné osy, prohodí se s ní a s pohledem přestane hýbat.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Zrušit", "ui.canvases": "Plátna", "ui.center": "Střed", + "ui.center_arc_follows_cursor": "Oblouk ze středu sleduje směr kurzoru", + "ui.center_arc_follows_cursor_tip": "Oblouk ze středu se kreslí po směru nebo proti směru hodinových ručiček podle toho, jak kurzor obíhá jeho střed. Vypnuto: vždy proti směru hodinových ručiček od začátku.", "ui.centerline": "Osa", "ui.chain": "Řetězec", "ui.chamfer": "Sražení", @@ -3570,6 +3986,7 @@ "ui.circle": "Kružnice", "ui.circular_pattern": "Kruhové pole", "ui.clear": "Vymazat", + "ui.clear_print_face": "Zrušit tiskovou plochu", "ui.clear_recent": "Vymazat nedávné", "ui.clear_the_selection": "Zrušit výběr", "ui.click_selects_whole_bodies": "Kliknutí vybírá celá tělesa", @@ -3604,6 +4021,15 @@ "ui.constant": "Konstantní", "ui.constraints_conflict": "Konflikt vazeb", "ui.construction": "Konstrukční", + "ui.construction.angle_hint": "Konstrukční přímka: klikněte na bod, pak zadejte úhel nebo vyberte směr", + "ui.construction.bisector_hint": "Osa úhlu: vyberte dvě protínající se přímky, pak požadované řešení", + "ui.construction.common_hint": "Společná tečna: vyberte dvě kružnice/oblouky, pak požadované řešení", + "ui.construction.contact_hint": "Tečna: vyberte kružnici/oblouk nebo otevřený splajn/kuželosečku, pak bod dotyku (volné křivky: koncové body)", + "ui.construction.external_hint": "Tečna: vyberte kružnici nebo oblouk, vnější bod, pak požadované řešení", + "ui.construction.free_hint": "Konstrukční přímka: klikněte na dva body; pravým tlačítkem začnete znovu", + "ui.construction.point_hint": "Konstrukční přímka: klikněte na bod, kterým má procházet", + "ui.construction.reference_hint": "Konstrukční přímka: vyberte přímku, pak bod; pravým tlačítkem změníte referenci", + "ui.construction.ribbon_label": "Pom. přímky", "ui.context_menu": "Místní nabídka", "ui.continuity_g0": "G0 hrana", "ui.continuity_g1": "G1 tečně", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Koncové měřítko", "ui.entities": "Prvky", "ui.equal": "Rovnost", + "ui.equation_end": "Konec", + "ui.equation_explicit": "Explicitní", + "ui.equation_form": "Tvar", + "ui.equation_note": "Proměnná je prosté číslo: sin(t) ji bere v radiánech. Výsledek bez jednotky je v jednotce návrhu.", + "ui.equation_parametric": "Parametrický", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalentní poloměr", "ui.expand": "Rozbalit", "ui.exportDxf": "Exportovat DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "NÁVRH", "ui.heading_draft": "ÚKOS", "ui.heading_edit": "UPRAVIT", + "ui.heading_equation_curve": "KŘIVKA Z ROVNICE", "ui.heading_extrude": "VYSUNUTÍ", "ui.heading_fillet": "ZAOBLENÍ", "ui.heading_finish_editing": "DOKONČIT ÚPRAVY", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Importovat…", "ui.infinite": "Nekonečná", "ui.insert": "Vložit", + "ui.insert_design.browse": "Procházet…", + "ui.insert_design.open_designs": "Otevřené návrhy", + "ui.insert_design.saved_file": "{name} obsahuje neuložené změny, proto byl vložen jeho uložený soubor. Uložte jej a pak použijte Aktualizovat odkazy, aby se změny převzaly.", + "ui.insert_design.unsaved": "Neuložené změny", "ui.inspect": "Analýza", "ui.intersect": "Průnik", "ui.isolate": "Izolovat", @@ -3994,12 +4433,29 @@ "ui.polygon": "Mnohoúhelník", "ui.preferences": "Předvolby", "ui.press_a_key": "stiskněte klávesu…", + "ui.print_face_cleared": "{body}: tisková plocha zrušena", + "ui.print_face_input": "Tisková plocha", + "ui.print_face_one_body": "Tisková plocha patří jednomu tělesu: odešlete toto těleso samostatně, abyste ji změnili", + "ui.print_face_pick": "Vyberte rovinnou plochu objemového tělesa, aby se stala tiskovou plochou", + "ui.print_face_set": "{body}: tisková plocha nastavena; Odeslat do sliceru položí těleso na ni", "ui.profile": "Profil", "ui.profile_plane": "Rovina profilu", "ui.profiles": "Profily", "ui.project": "Promítnout", "ui.project_face_edges_sketch": "Promítat hrany plochy do nových náčrtů", "ui.properties": "Vlastnosti", + "ui.properties.copy_matrix": "Kopírovat matici", + "ui.properties.first_moments": "Statické momenty", + "ui.properties.inertia": "Moment setrvačnosti", + "ui.properties.inertia_note": "K těžišti, v osách světa (g·{unit}²)", + "ui.properties.inertia_unavailable": "Není k dispozici: síť některého tělesa není uzavřená.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Měření…", + "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", "ui.pull_direction": "Směr vyjímání", "ui.quality": "Kvalita", "ui.quantity": "Počet", @@ -4045,6 +4501,7 @@ "ui.revolve": "Rotace", "ui.ribbon_exit": "Ukončit", "ui.ribbon_labels": "Zobrazovat text pod ikonami pásu karet", + "ui.ribbon_menus_skip_pinned": "Vynechat připnuté příkazy v rozbalovacích nabídkách", "ui.ribbon_pin_footer": "Připnuté příkazy se zobrazují na pásu karet. Najetím na řádek jej připnete nebo odepnete.", "ui.ribbon_workspace": "Pracovní prostředí", "ui.right": "Zprava", @@ -4062,6 +4519,7 @@ "ui.select": "Vybrat", "ui.selectOther": "Vybrat jiné…", "ui.selectTangentChain": "Vybrat tečný řetězec", + "ui.select_all": "Vybrat vše", "ui.selection": "Výběr", "ui.selection_modifiers": "Modifikátory výběru", "ui.selection_shift_add_only": "Shift+klik pouze přidává", @@ -4098,7 +4556,11 @@ "ui.sketch": "Náčrt", "ui.sketch_dimension": "Kóta náčrtu", "ui.sketch_grid": "Mřížka náčrtu", + "ui.sketch_intersect_geometry": "Geometrie", + "ui.sketch_intersect_projection_link": "Propojení promítnutí", + "ui.sketch_intersect_selection_filter": "Filtr výběru", "ui.sketch_modifiers": "Modifikátory náčrtu", + "ui.sketch_project_geometry": "Geometrie", "ui.sketch_scale": "Měřítko náčrtu", "ui.sketches": "Náčrty", "ui.slice": "Řez", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Správa", "ui.tab_sheet_metal": "Plech", "ui.tab_view": "Pohled", + "ui.tab_weldments": "Svařence", "ui.tangent": "Tečnost", "ui.taper_angle": "Úhel zúžení", "ui.tapped": "Se závitem", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Úhel špičky", "ui.to_object": "K objektu", "ui.tool_direction": "Směr nástroje", + "ui.toolbar_tabs_hidden": "Všechny karty panelu nástrojů jsou skryté. Kliknutím na + nebo kliknutím pravým tlačítkem sem některou zobrazíte.", "ui.top": "Shora", "ui.top_diameter": "Horní průměr", "ui.top_radius": "Horní poloměr", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Střed pohledu", "ui.viewport_status_clear": "Vypnout {name}", "ui.viewport_status_clear_hint": "Vypnout {name} · Esc", + "ui.viewport_status_edges": "Hrany: {count}", + "ui.viewport_status_from": "ze směru {direction}", "ui.viewport_status_key": "Zobrazit legendu pro {name}", "ui.viewport_status_minimum_radius": "Minimální poloměr", "ui.viewport_status_pairs": "Dvojice: {count}", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Stínovaný s hranami", "ui.visual_style": "Vizuální styl", "ui.visual_wireframe": "Drátový model", + "ui.wheel_zoom_speed": "Rychlost přiblížení kolečkem myši", "ui.width": "Šířka", "ui.window_system": "Okenní systém", "ui.window_system_auto": "Automaticky", @@ -4240,7 +4707,9 @@ "update.button": "Aktualizovat", "update.button_version": "Aktualizovat na {version}", "update.check_automatically": "Automaticky kontrolovat aktualizace", + "update.check_for_updates": "Zkontrolovat aktualizace", "update.check_now": "Zkontrolovat nyní", + "update.check_unavailable": "Pro tuto instalaci není kontrola aktualizací dostupná.", "update.checking": "Kontrola…", "update.copy": "Kopírovat", "update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte SolidCraft do Aplikací místo staré kopie a poté SolidCraft restartujte.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} je nainstalován. Pro použití restartujte.", "update.installer_started": "Instalátor se spustí po zavření SolidCraftu.", "update.later": "Připomenout později", + "update.managed": "K dispozici je aktualizace: aktualizujte SolidCraft v {manager}", "update.no_notes": "Tato verze nemá poznámky.", "update.package_hint": "Tato kopie pochází z {kind}. Stáhněte nový balíček a nainstalujte jej pomocí:", "update.prefs_dev": "Toto je vývojová verze ({version}): aktualizace nekontroluje.", "update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. SolidCraft nekontroluje.", "update.prefs_label": "Aktualizace", + "update.prefs_managed": "Toto je SolidCraft {version}, spravovaný aplikací {manager}. Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá; aktualizujte SolidCraft v {manager}.", "update.prefs_note": "Toto je SolidCraft {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.", "update.prefs_web": "Znovu načtěte stránku, abyste získali nejnovější webovou aplikaci. Pokud síť není dostupná nebo se aktualizace nestihne včas stáhnout, otevře se uložená verze; probíhající stahování pokračuje pro další načtení.", + "update.refresh_checking": "Hledání nejnovější verze…", + "update.refresh_fallback": "Novější verzi se nepodařilo ověřit; aktualizuje se na dříve nalezenou verzi {version}.", + "update.refresh_selected": "Aktualizace na {version}.", "update.release_page": "Otevřít stránku vydání", "update.restart": "Restartovat nyní", "update.restart_later": "Později", diff --git a/assets/locales/da.json b/assets/locales/da.json index ffd021801c..0e86005347 100644 --- a/assets/locales/da.json +++ b/assets/locales/da.json @@ -1,4 +1,5 @@ { + "(active rule)": "(aktiv regel)", "0 turns it off": "0 slår det fra", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Åbn GitHub-kladden nedenfor, og indsæt rapporten fra udklipsholderen.", "1. Open GitHub draft below. Your title and report will be filled in.": "1. Åbn GitHub-kladden nedenfor. Titel og rapport udfyldes automatisk.", @@ -8,6 +9,7 @@ "A Single hole snaps to the middle of its face and to the centres of its round edges": "Et enkelt hul snapper til fladens midtpunkt og centrene af dens cirkulære kanter", "A new configuration, a copy of the active one": "Opret en ny konfiguration som en kopi af den aktive", "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "Et placeret enkelt hul har et håndtag til at trække i. Hvis fladen vælges igen, flyttes hullet dertil i stedet for at blive fjernet", + "A short title for the problem (optional)": "En kort titel på problemet (valgfri)", "ARRANGE": "ARRANGER", "About SolidCraft": "Om SolidCraft", "Acceleration": "Acceleration", @@ -21,9 +23,12 @@ "Add Mid Radius": "Tilføj mellemradius", "Add Row": "Tilføj række", "Add a tool": "Tilføj et værktøj", + "Add all bodies": "Tilføj alle emner", + "Add bend": "Tilføj bukning", "Add condition": "Tilføj betingelse", "Add or edit rule": "Tilføj eller redigér regel", "Add to New Folder": "Føj til ny mappe", + "Add visible bodies": "Tilføj synlige emner", "Advanced": "Avanceret", "Against": "Mod", "Alignment": "Justering", @@ -36,7 +41,9 @@ "Also place them from the origin (fully constrained)": "Placer dem også fra origo (fuldt fastlåst)", "Anchor": "Forankring", "Angle 2": "Vinkel 2", + "Angle Display": "Visning af vinkel", "Animate": "Animér", + "Another bend on the far edge of the last flange": "Endnu en bukning på den yderste kant af den sidste flange", "App/system information and command settings are included. Review private details before sharing.": "Oplysninger om programmet, systemet og kommandoindstillingerne er inkluderet. Gennemgå private oplysninger, før du deler rapporten.", "Appearance name": "Navn på udseende", "Applied Radius": "Anvendt radius", @@ -46,14 +53,20 @@ "Apply your appearance first. Exports assigned appearances and materials created in this design.": "Anvend først dit udseende. Eksporterer tildelte udseender og materialer oprettet i dette design.", "Arc length": "Buelængde", "Area": "Areal", + "As Picked": "Som valgt", "As-Built": "Som bygget", "Assign Rule": "Tildel regel", "Attachment": "Tilknytning", "BLEND SURFACE": "OVERGANGSFLADE", "BOUNDARY SURFACE": "AFGRÆNSET OVERFLADE", "BROWSER": "MODELTRÆ", + "Ball": "Kugleled", "Bar": "Stang", + "Base Center": "Bundens centrum", + "Base Height": "Grundhøjde", + "Bend": "Bukning", "Bend Edges": "Bøjningskanter", + "Bend Line": "Bukkelinje", "Bend Line Position": "Bukkelinjens placering", "Bend Position": "Bukningens placering", "Bend Radius": "Bukkeradius", @@ -65,6 +78,7 @@ "Body (last if none)": "Legeme (det sidste, hvis intet er valgt)", "Body to split": "Legeme, der skal opdeles", "Body, or faces to take out": "Legeme eller flader, der skal fjernes", + "Both": "Begge", "Bottom Left": "Nederst til venstre", "Bottom Middle": "Nederst i midten", "Bottom Right": "Nederst til højre", @@ -96,6 +110,7 @@ "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Vælg et nyt plan. Skitsegeometri og mål bevarer deres todimensionelle koordinater.", "Choose…": "Vælg…", "Circle center": "Cirklens centrum", + "Circle or sphere": "Cirkel eller kugle", "Circular": "Cirkulær", "Circumference": "Omkreds", "Clockwise": "Med uret", @@ -112,12 +127,14 @@ "Config Table": "Konfig.tabel", "Configuration": "Konfiguration", "Configuration rules": "Konfigurationsregler", + "Constant radius": "Konstant radius", "Contact": "Kontakt", "Contact Sets": "Kontaktsæt", "Contact sets": "Kontaktsæt", "Continue on GitHub": "Fortsæt på GitHub", "Continuity": "Kontinuitet", "Control Radius": "Styreradius", + "Copies the definition only. Assign it from Physical Material when ready.": "Kopierer kun definitionen. Tildel den fra Fysisk materiale, når den er klar.", "Copy Error Message": "Kopiér fejlmeddelelse", "Copy Warning Message": "Kopiér advarselsmeddelelse", "Copy all licences": "Kopiér alle licenser", @@ -135,17 +152,22 @@ "Create Issue — review draft": "Opret issue — gennemgå kladde", "Create Issue…": "Opret fejlrapport…", "Cross curves": "Tværkurver", + "Ctrl+Enter continues on GitHub.": "Ctrl+Enter fortsætter på GitHub.", "Cube": "Terning", "Current orthographic view (1:1 mm)": "Aktuel visning uden perspektiv (1:1 mm)", "Current perspective view": "Aktuel perspektivvisning", + "Current plane": "Aktuelt plan", "Curv. Comb": "Krumningskam", "Curvature (G2)": "Krumningskontinuitet (G2)", "Curvature change (G3)": "Ændring i krumning (G3)", "Custom": "Brugerdefineret", "Customize Toolbar": "Tilpas værktøjslinje", "Customize…": "Tilpas…", + "Cylindrical": "Cylinderled", "Cylindrical face": "Cylindrisk flade", + "Deboss": "Fordybet prægning", "Delete": "Slet", + "Delete Folders and Features…": "Slet mapper og features…", "Delete Selected Features…": "Slet valgte features…", "Delete Selected Folders": "Slet valgte mapper", "Delete all": "Slet alle", @@ -159,6 +181,8 @@ "Designs you open or save appear here.": "Designs, du åbner eller gemmer, vises her.", "Determined by To Object. Choose Angle to edit the angular extent.": "Bestemmes af Til objekt. Vælg Vinkel for at ændre vinkeludstrækningen.", "Diameter": "Diameter", + "Did this assembly open the wrong way up?": "Blev denne samling åbnet med forkert side opad?", + "Did this part open the wrong way up?": "Blev dette emne åbnet med forkert side opad?", "Dimension new shapes": "Mål nye figurer", "Direction": "Retning", "Discard": "Kassér", @@ -168,19 +192,27 @@ "Don't ask again": "Spørg ikke igen", "Double-click to rename": "Dobbeltklik for at omdøbe", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dobbeltklik på den midterste museknap i visningen: Hjem (hjemmevisningen, der indrammer det viste), Tilpas (indrammer det viste og bevarer orienteringen) eller ingen handling", + "Drag to move the boss across the model. It snaps to sketch points on the face.": "Træk for at flytte skruebøsningen hen over modellen. Den snapper til skitsepunkter på fladen.", "Drag to move the hole; click to remove it": "Træk for at flytte hullet; klik for at fjerne det", + "Drag to move the rest across the model. It snaps to sketch points on the face.": "Træk for at flytte understøtningen hen over modellen. Den snapper til skitsepunkter på fladen.", + "Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Træk for at flytte snapforbindelsen hen over modellen. Den snapper til skitsepunkter på fladen.", + "Drag to point the catch in another direction.": "Træk for at pege låsen i en anden retning.", + "Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Træk for at dreje understøtningen i trin på 5°. Hold Alt eller Ctrl nede for at dreje den frit.", "Drive Joint": "Bevæg led", "Duration": "Varighed", "Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Hver ny figur får også mål fra skitsens origo, indtil intet af den er frit. En figur, der allerede er knyttet til origo eller til placeret geometri, får ingen.", "Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Hver figur, du tegner, får alle sine mål i de størrelser, du tegnede (indtastede værdier vinder). En størrelse, som skitsen allerede fastlægger, udelades.", + "Edge": "Kant", "Edges": "Kanter", "Edit": "Redigér", "Edit Profile Sketch": "Redigér profilskitse", + "Edit shortcuts": "Redigér genveje", "Enable rule": "Aktivér regel", "Enabled": "Aktiveret", "End Radius": "Slutradius", "End bulge": "Udbuling ved slutningen", "End continuity": "Overgang ved slutningen", + "Equal Distance": "Ens afstande", "Expand": "Fold ud", "Expected result (optional)": "Forventet resultat (valgfrit)", "Explode": "Adskil", @@ -208,6 +240,8 @@ "Faces": "Flader", "Faces / edges / vertices": "Flader / kanter / hjørnepunkter", "Faces to split": "Flader, der skal opdeles", + "Faces/Body": "Flader/emne", + "Factor must be a positive number": "Faktoren skal være et positivt tal", "Fastener": "Befæstigelse", "Favorites": "Favoritter", "Favourite": "Favorit", @@ -217,38 +251,57 @@ "Filter…": "Filtrér…", "Find in Browser": "Find i modeltræ", "Finish": "Afslut", + "First edge": "Første kant", "First plane": "Første plan", + "First point": "Første punkt", "Fit and G2 corners require Constant radius.": "Tilpasning og G2-hjørner kræver konstant radius.", "Fixed Face": "Fast flade", + "Flange Gap": "Flangemellemrum", "Flange thickness": "Flangetykkelse", + "Flanges": "Flanger", "Flat Pattern": "Flad afvikling", "Flat size": "Bredde mellem flader", + "Flat size X": "Udfoldet størrelse X", + "Flat size Y": "Udfoldet størrelse Y", "Follow camera (Look At sketches)": "Følg kamera (Se på skitser)", "Follow the camera (Look At sketches)": "Følg kameraet (Se på skitser)", "Font": "Skrifttype", + "Free Move": "Fri flytning", "From": "Fra", "From Bodies": "Fra emner", + "From Snap": "Fra snappunkt", "From edges": "Fra kanter", "Full Length": "Fuld længde", + "Full thread depth": "Fuld gevinddybde", + "Further Bends": "Flere bukninger", "G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2-hjørner kræver konstant radius og præcis tilpasning. Konstruktion af G2-hjørner understøttes endnu ikke.", "Gap": "Spalte", "Grid": "Gitter", "HISTORY": "HISTORIK", + "Halo under sketch curves": "Halo under skitsekurver", "Height": "Højde", + "Height From": "Højde fra", + "Hem Gap": "Ombukningsmellemrum", "Hidden edges": "Skjulte kanter", "History layout": "Historiklayout", "History names": "Navne i historikken", "Hole (optional)": "Hul (valgfrit)", + "Hole Depth": "Huldybde", + "Hole Diameter": "Huldiameter", "Holes": "Huller", "Holes (edges on them)": "Huller (kanterne omkring dem)", "Home view": "Hjemmevisning", "Horiz./Vert.": "Vandr./Lodr.", "I-Beam": "I-profil", "IMAGES": "BILLEDER", + "If you are offline or GitHub does not open, use Copy report and keep the text to submit later.": "Hvis du er offline, eller GitHub ikke åbner, skal du bruge Kopiér rapport og gemme teksten, så du kan indsende den senere.", + "Import material into design": "Importér materiale i designet", "Import…": "Importér…", "In This Design": "I dette design", "In place (surfaces)": "På stedet (overflader)", "In plane": "I planet", + "Included": "Indesluttet", + "Inner Sharp": "Indvendig skarp", "Inside": "Indvendigt", "Instant": "Øjeblikkelig", "Interface font": "Skrifttype i brugergrænsefladen", @@ -258,12 +311,16 @@ "Issue title": "Titel på issue", "Items": "Elementer", "JOINTS": "LED", + "Joint 1": "Led 1", + "Joint 2": "Led 2", "Joint Origin1": "Lednulpunkt1", + "K Factor": "K-faktor", "Keep (points on it)": "Behold (punkter på fladen)", "Keep Tools": "Behold værktøjsemner", "Keep of the other surface": "Behold fra den anden overflade", "Key a joint's value at this step": "Indsæt en nøgle for leddets værdi på dette trin", "Keys": "Nøgler", + "Knife Edge": "Knivskarp kant", "L-Angle": "L-profil", "LIBRARY": "BIBLIOTEK", "LINKS": "LINKS", @@ -276,15 +333,18 @@ "Letter Spacing": "Tegnafstand", "Library": "Bibliotek", "Library file": "Biblioteksfil", + "Library response is missing": "Svar fra biblioteket mangler", "Limits": "Grænser", "Line Spacing": "Linjeafstand", "Line Type": "Linjetype", + "Line on plane": "Linje på plan", "Lines": "Linjer", "Listing the installed fonts…": "Henter listen over installerede skrifttyper…", "Load library": "Indlæs bibliotek", "Local library": "Lokalt bibliotek", "Loop": "Løkke", "M5 or M5x0.5": "M5 eller M5x0.5", + "M5, M5x0.5, 1/4-20 UNC": "M5, M5x0.5, 1/4-20 UNC", "MANAGE": "ADMINISTRÉR", "MATERIALS": "MATERIALER", "MODEL": "MODEL", @@ -294,12 +354,17 @@ "Mass Center": "Massecentrum", "Matched restrictions check the final values.": "Matchede begrænsninger kontrollerer de endelige værdier.", "Material": "Materiale", + "Material Outside": "Materiale udvendigt", + "Material copied. Existing body assignments are unchanged.": "Materialet er kopieret. Eksisterende tildelinger til emner er uændrede.", "Max": "Maks.", + "Max Thickness": "Maks. tykkelse", "Maximum": "Maksimum", "Mesh body": "Meshlegeme", "Mid Radius": "Mellemradius", "Middle double-click": "Dobbeltklik med midterste museknap", "Min": "Min.", + "Min Draft": "Min. slipvinkel", + "Min Thickness": "Min. tykkelse", "Minimum": "Minimum", "Mirror Plane": "Spejlingsplan", "Mitre": "Gering", @@ -317,7 +382,9 @@ "Move later": "Flyt senere", "Move up": "Flyt op", "Name": "Navn", + "Name on import": "Navn ved import", "Nearest": "Nærmeste", + "Neutral plane": "Neutralt plan", "New": "Ny", "New Comp.": "Ny komp.", "New Sketch": "Ny skitse", @@ -326,45 +393,62 @@ "No crash reports.": "Ingen nedbrudsrapporter.", "No matching appearance presets.": "Ingen matchende udseendeforudindstillinger.", "No matching appearances": "Ingen matchende udseender", + "No matching physical materials.": "Ingen matchende fysiske materialer.", "No visible bodies or sketches to export.": "Der er ingen synlige emner eller skitser at eksportere.", + "Nominal Radius": "Nominel radius", "Nominal diameter": "Nominel diameter", "None": "Ingen", "Not available yet": "Endnu ikke tilgængelig", + "Not saved yet": "Endnu ikke gemt", "Notch": "Udskæring", "OUTPUT": "OUTPUT", "Of": "Af", "On your computer": "På din computer", "Opacity": "Opacitet", + "Open GitHub": "Åbn GitHub", "Open GitHub draft": "Åbn GitHub-kladde", + "Open GitHub issue form": "Åbn GitHubs formular til issues", + "Open bodies (no volume)": "Åbne emner (uden volumen)", "Open library...": "Åbn bibliotek...", "Open local libraries in the desktop app. You can export this design's library here.": "Åbn lokale biblioteker i desktopappen. Du kan eksportere dette designs bibliotek her.", + "Open surface — no volume": "Åben overflade — uden volumen", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Parametrisk 3D-CAD i Rust med åben kildekode. Licenseret under MIT OR Apache-2.0.", "Opens your browser and copies the report text for you to paste into GitHub.": "Åbner din browser og kopierer rapportteksten, så du kan indsætte den på GitHub.", + "Opens your browser and copies the report text. Review and submit your report on GitHub.": "Åbner din browser og kopierer rapportteksten. Gennemgå og indsend rapporten på GitHub.", "Opens your browser. Review and submit your report on GitHub.": "Åbner din browser. Gennemgå og indsend rapporten på GitHub.", "Operator": "Operator", "Orbit (right drag)": "Rotér visningen (træk med højre museknap)", "Other side": "Anden side", "Other surface (mutual trim)": "Anden overflade (trim begge)", + "Other way": "Den anden vej", + "Outer Sharp": "Udvendig skarp", "Outer radius": "Udvendig radius", "Outside": "Udvendigt", "Overall length": "Samlet længde", "Param. Graph": "Param.graf", "Parameter": "Parameter", + "Part length must be greater than zero and at most 2000 mm": "Delens længde skal være større end nul og højst 2000 mm", "Parts": "Dele", "Pass through": "Gå igennem", "Path": "Bane", "Pattern": "Mønster", "Per Edge": "Pr. kant", + "Physical material definitions": "Definitioner af fysiske materialer", + "Pick a part size": "Vælg en delstørrelse", "Pick the tool bodies": "Vælg værktøjsemnerne", "Picked": "Valgt", "Picked point": "Valgt punkt", + "Pin-slot": "Stift i slids", "Pitch": "Stigning", "Pivot": "Drejepunkt", "Place at the origin instead": "Placér ved nulpunktet i stedet", + "Planar": "Planled", "Plastic Rules": "Plastregler", "Play the joint's motion in the view": "Afspil leddets bevægelse i visningen", "Playback": "Afspilning", "Plot": "Diagram", + "Point on line": "Punkt på linje", + "Point on plane": "Punkt på plan", "Position": "Position", "Position (0 to 1)": "Position (0 til 1)", "Position (G0)": "Positionskontinuitet (G0)", @@ -377,6 +461,7 @@ "Profiles or curves": "Profiler eller kurver", "Profiles/Curves": "Profiler/kurver", "Quantity": "Antal", + "Quantity must be at least 2": "Antal skal være mindst 2", "RECENT": "SENESTE", "REFERENCE": "REFERENCE", "RULED SURFACE": "REGELFLADE", @@ -384,12 +469,15 @@ "Radius": "Radius", "Radius Fit": "Tilpasning af radius", "Ratio": "Forhold", + "Read-only list: shortcuts can't be changed here.": "Skrivebeskyttet liste: Genveje kan ikke ændres her.", "Reattach selected face": "Tilknyt den valgte flade igen", "Recover": "Gendan", "Recover unsaved design?": "Gendan design, der ikke er gemt?", "Rectangular": "Rektangulær", "Rectangular Tube": "Rektangulært hulprofil", "Reference face": "Referenceflade", + "Relief Depth": "Aflastningsdybde", + "Relief Width": "Aflastningsbredde", "Remove Tools": "Fjern værktøjsemner", "Remove condition": "Fjern betingelse", "Remove from Folder": "Fjern fra mappe", @@ -399,11 +487,16 @@ "Remove screenshot": "Fjern skærmbillede", "Remove the column": "Fjern kolonnen", "Remove the track": "Fjern sporet", + "Remove this bend": "Fjern denne bukning", "Remove this point": "Fjern dette punkt", "Rename Feature": "Omdøb feature", "Rename Folder": "Omdøb mappe", + "Render Stage": "Renderingskulisse", + "Reopen designs": "Genåbn designs", + "Reopen the designs that were open when SolidCraft was last closed": "Genåbn de designs, der var åbne, da SolidCraft sidst blev lukket", "Replace existing library file": "Erstat eksisterende biblioteksfil", "Report a bug — review a screenshot and open a GitHub draft": "Rapportér en fejl — gennemgå et skærmbillede, og åbn en GitHub-kladde", + "Report copied. Keep this text to submit on GitHub when you are online.": "Rapporten er kopieret. Gem teksten, så du kan indsende den på GitHub, når du er online.", "Report text copied. Paste it into GitHub and submit your report there.": "Rapportteksten er kopieret. Indsæt den på GitHub, og indsend rapporten der.", "Reports are public. A GitHub account is required.": "Rapporter er offentlige. Du skal have en GitHub-konto.", "Reset Toolbar": "Nulstil værktøjslinje", @@ -414,6 +507,7 @@ "Restore this studio's tools": "Gendan værktøjerne i dette arbejdsområde", "Result": "Resultat", "Retake screenshot": "Tag skærmbillede igen", + "Reveal Height": "Synlig højde", "Reverse": "Vend om", "Reverse Direction": "Vend retning", "Revert": "Tilbagefør", @@ -422,12 +516,15 @@ "Revert Position": "Tilbagefør position", "Review the report and screenshot before opening GitHub.": "Gennemgå rapport og skærmbillede, før du åbner GitHub.", "Review your report on GitHub and click Submit new issue to publish it.": "Gennemgå rapporten på GitHub, og klik på Submit new issue for at offentliggøre den.", + "Revolute": "Drejeled", "Rib Length": "Ribbelængde", "Rib Offset": "Ribbeforskydning", "Rib Thickness": "Ribbetykkelse", "Ribs": "Ribber", "Right Middle": "Til højre i midten", + "Rigid": "Stivt led", "Rim Edge": "Randkant", + "Rim face": "Kantflade", "Ring of tools on the right button": "Værktøjsring på højre museknap", "Roll Back Here": "Rul tilbage hertil", "Roll History Marker Here": "Flyt tilbagerulningsmarkør hertil", @@ -440,6 +537,7 @@ "Rules run from top to bottom. Matched drives change effective values.": "Regler køres oppefra og ned. Matchede drivere ændrer de effektive værdier.", "SECTION": "SNIT", "SURFACE QUALITY": "OVERFLADEKVALITET", + "Same way": "Samme vej", "Save Rule": "Gem regel", "Save View": "Gem visning", "Save screenshot…": "Gem skærmbillede…", @@ -451,16 +549,28 @@ "Search": "Søg", "Search commands": "Søg efter kommandoer", "Search this library": "Søg i dette bibliotek", + "Second edge": "Anden kant", "Second plane": "Andet plan", + "Second point": "Andet punkt", "Section": "Snit", "Section (optional)": "Tværsnit (valgfrit)", "Section position": "Profilets placering", "Sections (one sketch each)": "Tværsnit (én skitse til hvert)", "Select Other": "Vælg andet", "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).": "Vælg andet: Hold venstre museknap nede uden at flytte markøren på modellen (eller højreklik › Vælg andet…) for at vise alt under markøren. Klik igen samme sted for at vælge næste objekt. Dobbeltklik på den midterste museknap for at gå til Hjem (Indstillinger › Navigation).", + "Select Same Look": "Vælg samme udseende", "Select bodies (a face picks its body)": "Vælg emner (en flade vælger sit emne)", "Select bodies or faces instead": "Vælg emner eller flader i stedet", "Select edges": "Vælg kanter", + "Select points of the sketch being edited": "Vælg punkterne i den skitse, der redigeres", + "Select the centre the pattern turns around first": "Vælg først det centrum, som mønsteret drejer omkring", + "Select the curves to offset first": "Vælg først de kurver, der skal forskydes", + "Select the mirror line": "Vælg spejlingslinjen", + "Select the sketch curves or points to mirror first": "Vælg først de skitsekurver eller punkter, der skal spejles", + "Select the sketch curves or points to move first": "Vælg først de skitsekurver eller punkter, der skal flyttes", + "Select the sketch curves or points to pattern first": "Vælg først de skitsekurver eller punkter, der skal indgå i mønsteret", + "Select the sketch curves or points to scale first": "Vælg først de skitsekurver eller punkter, der skal skaleres", + "Select the sketch curves or points to transform first": "Vælg først de skitsekurver eller punkter, der skal transformeres", "Selection filter": "Markeringsfilter", "Semantic faces share their appearance across instances.": "Semantiske flader deler udseende på tværs af instanser.", "Send your report on GitHub": "Send din rapport på GitHub", @@ -468,6 +578,7 @@ "Shape": "Form", "Sheet Rules": "Pladeregler", "Show Agent Cursor": "Vis agentmarkør", + "Show ghost": "Vis skyggebillede", "Show in folder": "Vis i mappe", "Show the pointer an AI agent moves (#35)": "Vis markøren, som en AI-agent bevæger (#35)", "Show tool at cursor": "Vis værktøj ved markøren", @@ -479,19 +590,25 @@ "Sketch geometry and profiles": "Skitsegeometri og profiler", "Sketch plane has no body-face attachment.": "Skitseplanet er ikke tilknyttet en flade på et emne.", "Sketch points": "Skitsepunkter", + "Slider": "Glideled", "Slot width": "Slidsbredde", "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.", + "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", "Speed": "Hastighed", "Splitting tool": "Opdelingsværktøj", "Square": "Kvadrat", "Stand it up": "Stil op", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Stil op: En kvart omdrejning om X som én flyttefeature, der kan fortrydes.", + "Stand this assembly up?": "Stil denne samling op?", "Stand this part up?": "Stil dette emne op?", "Start angle": "Startvinkel", "Start bulge": "Udbuling ved starten", "Start continuity": "Overgang ved starten", + "Stationary Side": "Fast side", "Step": "Trin", "Steps": "Trin", "Steps to reproduce (optional)": "Trin til at genskabe problemet (valgfrit)", @@ -502,33 +619,50 @@ "Style": "Stil", "Summary": "Resumé", "Suppress": "Undertryk", + "Suppress Selected Folders": "Undertryk valgte mapper", "Suppressed": "Undertrykt", "Surface": "Overflade", "Surface, or faces to turn over": "Overflade eller flader, der skal vendes", + "Surfaces": "Overflader", "Swap Ends": "Ombyt ender", "TRIM": "TRIM", + "Tangent": "Tangent", "Tangent (G1)": "Tangentkontinuitet (G1)", "Tangent Chain": "Tangentkæde", "Tangent chain": "Tangentkæde", + "Tangent to Edge": "Tangent til kant", "Tap drill diameter": "Diameter på gevindbor", "Target": "Mål", "Target Body": "Mållegeme", + "Target Face (optional)": "Målflade (valgfri)", "Tear": "Rivning", "Technical details": "Tekniske detaljer", "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Den stiplede skitse er en gemt planreference. Vælg en ny flade for at reparere tilknytningen.", + "The design changed. Reopen Change Part Size for the component.": "Designet er ændret. Åbn Skift standarddelens størrelse igen for komponenten.", + "The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Retningen skal være en akse eller en ret skitsekurve, der ligger i skitseplanet", + "The mirror line cannot also be mirrored": "Spejlingslinjen kan ikke også spejles", + "The mirror line must be a straight curve of this sketch, not an origin axis": "Spejlingslinjen skal være en ret kurve i denne skitse og ikke en akse fra nulpunktet", "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Den gemte oprindelige komponentforekomst mangler. Vælg en gyldig oprindelig placering før ny tilknytning.", "Then": "Så", "These features will be deleted:": "Disse features slettes:", "These features will fail:": "Disse features vil fejle:", "Thickness Outside": "Tykkelse udad", + "Thickness Variation": "Tykkelsesvariation", "Thin": "Tynd", "Thin Extrude": "Tynd ekstrudering", + "Third plane": "Tredje plan", + "Third point": "Tredje punkt", "This child is shared. Deleting it removes it from every copy of its parent.": "Dette underobjekt er delt. Sletning fjerner det fra alle kopier af dets overordnede objekt.", "This child is shared. Position changes apply to every copy of its parent.": "Dette underobjekt er delt. Positionsændringer gælder for alle kopier af dets overordnede objekt.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Denne komponent ligger i en delsamling, der er placeret flere gange: Justering flytter den i alle placeringer.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Denne flade kan ikke give en varig tilknytning. Vælg en anden flade, eller vælg udtrykkeligt et fast plan.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Dette billede er endnu ikke vedhæftet på GitHub. Kopiér eller gem det, og vedhæft det derefter på GitHub.", + "Thread depth": "Gevinddybde", + "Thread diameter": "Gevinddiameter", + "Through point": "Gennem punkt", "To": "Til", "To Offset": "Til forskydning", + "To Snap": "Til snappunkt", "To edges": "Til kanter", "Toe radius": "Tåradius", "Tolerance": "Tolerance", @@ -542,9 +676,11 @@ "Trimming tool (plane, face or curves)": "Trim med plan, flade eller kurver", "Turn off section analysis before exporting the view.": "Slå snitanalyse fra, før visningen eksporteres.", "Type": "Type", + "Type a distance or an angle to move by": "Skriv en afstand eller en vinkel at flytte med", "UNTRIM": "FJERN BESKÆRING", "Unit": "Enhed", "Unsuppress": "Ophæv undertrykkelse", + "Unsuppress Selected Folders": "Ophæv undertrykkelse af valgte mapper", "Updating the preview": "Opdaterer forhåndsvisningen", "Use active author placement": "Brug den aktive komponents placering", "Use fixed plane": "Brug fast plan", @@ -585,10 +721,26 @@ "Z angle": "Z-vinkel", "Z distance": "Z-afstand", "Zebra": "Zebra", + "`joint` must be an id or a name": "`joint` skal være et id eller et navn", + "`params` must be an object": "`params` skal være et objekt", + "`values` must be a list": "`values` skal være en liste", + "a chamfer needs a corner of exactly two lines": "en affasning kræver et hjørne med præcis to linjer", + "a file operation is in progress": "en filhandling er i gang", + "a picked curve is no longer in a sketch": "en valgt kurve er ikke længere i en skitse", + "a picked curve's sketch has not been solved": "skitsen for en valgt kurve er ikke blevet løst", + "a picked edge has no points": "en valgt kant har ingen punkter", + "a picked edge is gone": "en valgt kant findes ikke længere", + "a picked edge's body is gone": "emnet for en valgt kant findes ikke længere", + "add a key for a joint first": "tilføj først et animationspunkt for et led", + "ai.connected_toast": "SolidCraft er nu tilgængeligt i {clients}. Genstart dem for at bruge det (Indstillinger > Assistent).", "ai.prefs.allow_control": "Tillad AI-agenter at oprette forbindelse (kontrolkanal / MCP)", "ai.prefs.allow_control_note": "Åbner kun en kontrolkanal på denne computer (127.0.0.1, port 27878 eller den næste ledige). Et program, der opretter forbindelse, skal også fremvise et hemmeligt token, som kun din brugerkonto kan læse, og som oprettes på ny ved hver start. Træder i kraft, næste gang SolidCraft starter. Fra: Ingen port lytter, medmindre du starter SolidCraft med --control.", "ai.prefs.availability": "Assistentstyring", "ai.prefs.background_commands": "Kør agentkommandoer i baggrunden", + "ai.prefs.connect_apps": "Gør SolidCraft tilgængeligt for AI-apps på denne computer", + "ai.prefs.connect_apps_note": "Når SolidCraft starter, tilføjes det som MCP-server i de AI-apps, der findes på denne computer (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), så en agent kan bruge det uden nogen opsætning. Kun posten solidcraft tilføjes, eller dens sti opdateres; en post, du har ændret, røres ikke, og filen sikkerhedskopieres først. Slået fra: De poster, der blev tilføjet, fjernes. Hjælp > Forbind AI-assistenter… gør dette manuelt.", + "ai.prefs.connect_at_start": "Forbind den senest brugte agent, når SolidCraft starter", + "ai.prefs.connect_at_start_note": "Starter den agent, du forbandt senest (eller standardagenten), i baggrunden, hver gang SolidCraft starter, så den er klar til at chatte. Ikke når SolidCraft startes med --control. Hvis den ikke kan oprette forbindelse, viser Assistenten Forbindelsen mislykkedes og en knap Prøv igen.", "ai.prefs.follow_camera": "Vend kameraet mod skitser", "ai.prefs.follow_camera_off": "Automatisk kamerafølgning er slået fra.", "ai.prefs.follow_camera_on": "Kameraet vender sig mod skitser, som agenten opretter.", @@ -596,6 +748,8 @@ "ai.prefs.make_default": "Brug som standard", "ai.prefs.no_agents": "Ingen agenter endnu. Tilføj en agent, eller opdatér for at finde installerede agenter.", "ai.prefs.no_saved_approvals": "Ingen gemte godkendelser.", + "ai.prefs.open_at_start": "Åbn panelet Assistent, når SolidCraft starter", + "ai.prefs.open_at_start_note": "Viser panelet Assistent (eller dets eget vindue), hver gang SolidCraft starter. Ikke når SolidCraft startes med --control.", "ai.prefs.override_all_warning": "Denne agent kan gemme, overskrive, eksportere eller erstatte designet uden at spørge.", "ai.prefs.permission_all": "Tillad alt", "ai.prefs.permission_all_summary": "Tilgængelige SolidCraft-værktøjer kører uden at spørge.", @@ -619,16 +773,22 @@ "appearance.roughness": "Ruhed", "appearance.surface_response": "Overfladens reaktion på lys", "at the face": "ved fladen", + "bom.group_children": "Underdele i rækken for deres del", "browser.body_name": "Emne{number}", + "browser.calibrate_click_a": "Klik på billedet for punkt A, eller skriv dets koordinater.", + "browser.calibrate_click_b": "Klik på billedet for punkt B, eller skriv dets koordinater.", "browser.calibrate_title": "Kalibrér: {name}", "browser.capture_many": "{count} komponenter er flyttet, men deres positioner er ikke registreret.", "browser.capture_note": "Registrér beholder den nye position; Fortryd sætter den tilbage.", "browser.capture_one": "En komponent er flyttet, men dens position er ikke registreret.", "browser.component_name": "Komponent{number}", "browser.edit_canvas_title": "Redigér lærred: {name}", + "browser.edit_point_cloud_title": "Redigér punktsky: {name}", "browser.move_title": "Flyt: {name}", "browser.parameters": "Parametre ({count})", "browser.precision": "Præcision: {precision}", + "browser.sketch_attachment_lost": "{name} (tilknytning mistet)", + "browser.sketch_editing": "{name} (redigeres)", "browser.sketch_name": "Skitse{number}", "browser.units": "Enheder: {units}", "cam.flat": "Flad kontaktflade", @@ -638,15 +798,36 @@ "cam.roller": "Rulle", "cam.roller_pick": "Rulle (valgfri)", "cam.tip_pick": "Spids (valgfri)", + "camera.shot_pitch": "Stampning", + "camera.shot_roll": "Rulning", + "camera.shot_yaw": "Krøjning", + "canvas.decal_place_hint": "Klik på en flade for at placere dekalen", + "canvas.place_hint": "Klik på et plan eller en flade for at placere referencebilledet", + "choose a profile": "vælg en profil", + "choose the cam's and the follower's joints": "vælg knastens og følgerens led", + "choose the joints to couple": "vælg de led, der skal kobles", + "click a plane or planar face": "klik på et plan eller en plan flade", + "click sketch curves": "klik på skitsekurver", "cmd.appearance_assign": "Udseende", + "cmd.appearance_chain": "Udseendekæde", + "cmd.appearance_copy": "Kopiér udseende", + "cmd.appearance_favorite": "Favoritudseende", "cmd.appearance_library": "Udseendebibliotek", "cmd.appearance_list": "Vis udseender", + "cmd.appearance_paint": "Påfør udseende", + "cmd.appearance_paste": "Indsæt udseende", + "cmd.appearance_pick_up": "Optag udseende", + "cmd.appearance_replace": "Erstat udseende", "cmd.assembly_bom": "Stykliste", + "cmd.body.print_face": "Printflade", "cmd.body_rename": "Omdøb emne", + "cmd.browser_collapse_all": "Fold alle sammen", + "cmd.browser_expand_all": "Fold alle ud", "cmd.browser_group": "Ny gruppe", "cmd.browser_move": "Flyt elementer i modeltræ", "cmd.browser_order": "Sortér elementer i modeltræ", "cmd.browser_rename_group": "Omdøb gruppe", + "cmd.browser_reparent": "Flyt til komponent", "cmd.browser_ungroup": "Ophæv gruppering", "cmd.browser_visibility": "Vis/skjul", "cmd.canvas_calibrate": "Kalibrér referencebillede", @@ -659,9 +840,13 @@ "cmd.component_break_link": "Bryd forbindelse", "cmd.component_capture_position": "Registrér position", "cmd.component_copies": "Find identiske kopier", + "cmd.component_copy_bodies": "Indsæt emner", "cmd.component_create": "Ny komponent", + "cmd.component_cycle_color": "Skift mellem komponentfarver", "cmd.component_delete": "Slet komponent", + "cmd.component_edit_in_place": "Redigér på stedet", "cmd.component_extract": "Udtræk til fil", + "cmd.component_finish_edit": "Afslut redigering", "cmd.component_from_bodies": "Opret komponenter fra emner", "cmd.component_insert_design": "Indsæt design", "cmd.component_links": "Vis forbindelser", @@ -669,7 +854,9 @@ "cmd.component_metadata": "Egenskaber for delen", "cmd.component_metadata_set": "Redigér delens egenskaber", "cmd.component_move_bodies": "Flyt emner til komponent", + "cmd.component_move_features": "Flyt features til komponent", "cmd.component_move_sketches": "Flyt skitser til komponent", + "cmd.component_paste_from_design": "Indsæt fra et andet design", "cmd.component_paste_new": "Indsæt som ny", "cmd.component_properties": "Komponentens fysiske egenskaber", "cmd.component_relink": "Skift kilde", @@ -721,8 +908,10 @@ "cmd.document_metadata": "Designegenskaber", "cmd.document_metadata_remove": "Fjern designegenskab", "cmd.document_metadata_set": "Redigér designegenskaber", + "cmd.document_revision": "Designrevision", "cmd.document_units": "Skift designets enheder", "cmd.edit_checkpoint": "Kontrolpunkt", + "cmd.edit_repeat": "Gentag sidste kommando", "cmd.edit_restore_checkpoint": "Gendan kontrolpunkt", "cmd.engine_commands": "Vis kommandoer", "cmd.engine_describe": "Beskriv kommando", @@ -737,6 +926,7 @@ "cmd.file_insert_step": "Indsæt STEP", "cmd.file_open_in_easyprint": "Åbn med EasyPrint", "cmd.file_save_mesh": "Gem som mesh", + "cmd.file_send_to_slicer": "Send til slicer", "cmd.file_slicers": "Installerede slicere", "cmd.frame_member": "Rammeelement", "cmd.frame_profile_insert": "Indsæt profil", @@ -745,6 +935,8 @@ "cmd.inspect_accessibility": "Tilgængelighedsanalyse", "cmd.inspect_center_of_mass": "Massecentrum", "cmd.inspect_clearance": "Frigangskontrol", + "cmd.inspect_colored_components": "Aktivér komponentfarver", + "cmd.inspect_colored_components_disable": "Deaktivér komponentfarver", "cmd.inspect_compare": "Sammenlign emner", "cmd.inspect_continuity": "Kontinuitetsanalyse", "cmd.inspect_curvature_comb": "Krumningskamanalyse", @@ -754,6 +946,7 @@ "cmd.inspect_interference": "Overlap", "cmd.inspect_isocurve": "Isokurveanalyse", "cmd.inspect_minimum_radius": "Analyse af mindste radius", + "cmd.inspect_parameter_sweep": "Parametersweep", "cmd.inspect_silhouette": "Silhuet", "cmd.inspect_zebra": "Zebraanalyse", "cmd.joint_as_built": "Led i eksisterende position", @@ -800,11 +993,13 @@ "cmd.motion_trace": "Bevægelsesspor", "cmd.motion_trace_edit": "Redigér bevægelsesspor", "cmd.motion_trace_keep": "Behold bevægelsesspor", + "cmd.occurrence_align": "Justér komponentforekomst", "cmd.occurrence_copy": "Indsæt (komponentforekomst)", "cmd.occurrence_drag": "Træk forekomst", "cmd.occurrence_ground": "Fastgør", "cmd.occurrence_move": "Flyt komponentforekomst", "cmd.occurrence_properties": "Komponentforekomstens fysiske egenskaber", + "cmd.occurrence_reparent": "Flyt til komponent", "cmd.parameters_add": "Tilføj brugerparameter", "cmd.parameters_change": "Redigér parametre", "cmd.parameters_comment": "Kommentér parameter", @@ -829,17 +1024,73 @@ "cmd.plastic_rest": "Understøtning", "cmd.plastic_rules": "Vis plastregler", "cmd.plastic_snap_fit": "Snapforbindelse", + "cmd.pointcloud_delete": "Slet punktsky", + "cmd.pointcloud_edit": "Redigér punktsky", + "cmd.pointcloud_insert": "Indsæt punktsky", + "cmd.pointcloud_list": "Punktskyer", + "cmd.render_aging_describe": "Lagdelt overfladeældning", "cmd.render_backends": "Renderingsmotorer", + "cmd.render_batch": "Render alle optagelser", + "cmd.render_camera_fit": "Indram motiv", + "cmd.render_camera_get": "Undersøg renderingskamera", "cmd.render_cancel": "Annullér rendering", + "cmd.render_compare": "Sammenlign billeder", + "cmd.render_effects_describe": "Linse- og kreative effekter", "cmd.render_export": "Eksportér renderet billede", + "cmd.render_hdr_inspect": "Undersøg HDR-kilde", + "cmd.render_library_appearance_apply": "Anvend uigennemsigtigt udseende fra biblioteket", + "cmd.render_library_describe": "Undersøg ressource i renderingsbiblioteket", + "cmd.render_library_environment_apply": "Anvend indbyggede HDR-omgivelser", + "cmd.render_library_list": "Gennemse renderingsbiblioteket", + "cmd.render_library_physical_prepare": "Forbered fysisk reference", + "cmd.render_library_preview": "Undersøg originalt teksturkort", + "cmd.render_library_studio_apply": "Anvend designede studiolys", + "cmd.render_library_studio_prepare": "Forbered designede studiolys", + "cmd.render_library_surface_bind": "Gem overfladebinding", + "cmd.render_library_surface_prepare": "Forbered designede teksturkort", + "cmd.render_library_surface_unbind": "Fjern overfladebinding", + "cmd.render_library_targets": "Find mål i renderingsbiblioteket", + "cmd.render_lights_add": "Tilføj lys", + "cmd.render_lights_duplicate": "Duplikér renderingslys", + "cmd.render_lights_list": "Vis renderingslys", + "cmd.render_lights_mute": "Mute lys", + "cmd.render_lights_remove": "Fjern renderingslys", + "cmd.render_lights_set": "Angiv renderingslys", + "cmd.render_lights_solo": "Solo lys", + "cmd.render_optics_bind": "Gem optisk binding", + "cmd.render_optics_unbind": "Fjern optisk binding", + "cmd.render_output_size": "Angiv outputstørrelse for rendering", "cmd.render_preflight": "Kontrollér, om billedet kan renderes", + "cmd.render_preflight_copy": "Kopiér rapport fra renderingskontrol", + "cmd.render_preset_apply": "Anvend indbygget studio", + "cmd.render_preset_resolve": "Gennemgå indbygget studio", + "cmd.render_quality_prepare": "Gennemgå fast sample-budget", + "cmd.render_result_close": "Luk renderet billede", + "cmd.render_result_copy": "Kopiér renderet billede", + "cmd.render_result_pin": "Bevar vist renderingsresultat", + "cmd.render_result_regrade": "Farvegradér lineær master igen", + "cmd.render_result_view": "Vis renderet billede", "cmd.render_scene": "Vis oplysninger om scenen", "cmd.render_settings_apply": "Brug kamera", + "cmd.render_settings_capture_hdr": "Gem renderingsindstillinger med HDR", "cmd.render_settings_get": "Renderingsindstillinger", "cmd.render_settings_set": "Gem renderingsindstillinger", + "cmd.render_shot_duplicate": "Duplikér renderoptagelse", + "cmd.render_shot_remove": "Fjern renderoptagelse", + "cmd.render_shot_rename": "Omdøb renderoptagelse", + "cmd.render_stage_backdrop": "Gem fysisk renderingsbaggrund", + "cmd.render_stage_clear": "Fjern kulisse", + "cmd.render_stage_ground": "Gem renderingsgrundplan", "cmd.render_start": "Render billede", "cmd.render_status": "Status for rendering", "cmd.select_clear": "Ryd markering", + "cmd.settings_profile_apply": "Anvend indstillingsprofil", + "cmd.settings_profile_delete": "Slet indstillingsprofil", + "cmd.settings_profile_export": "Eksportér indstillingsprofil", + "cmd.settings_profile_import": "Importér indstillingsprofil", + "cmd.settings_profile_list": "Indstillingsprofiler", + "cmd.settings_profile_rename": "Omdøb indstillingsprofil", + "cmd.settings_profile_save": "Gem indstillingsprofil", "cmd.sheet_convert": "Konvertér til plade", "cmd.sheet_export_bend_table": "Eksportér bukketabel", "cmd.sheet_export_dxf": "Eksportér udfoldning som DXF", @@ -848,10 +1099,22 @@ "cmd.sheet_fold": "Buk", "cmd.sheet_hem": "Ombukning", "cmd.sheet_manage_rules": "Pladeregler", + "cmd.sheet_path_flange": "Flange langs bane", "cmd.sheet_perforate": "Perforér", "cmd.sheet_refold": "Buk tilbage", "cmd.sheet_rules": "Vis pladeregler", "cmd.sheet_unfold": "Fold ud", + "cmd.sketch.construction_line": "Hjælpelinje", + "cmd.sketch.construction_line.angle": "Hjælpelinje med vinkel", + "cmd.sketch.construction_line.bisector": "Vinkelhalveringslinje", + "cmd.sketch.construction_line.horizontal": "Vandret hjælpelinje", + "cmd.sketch.construction_line.parallel": "Parallel hjælpelinje", + "cmd.sketch.construction_line.perpendicular": "Vinkelret hjælpelinje", + "cmd.sketch.construction_line.tangent_at": "Tangent i kurvepunkt", + "cmd.sketch.construction_line.tangent_external": "Tangent gennem eksternt punkt", + "cmd.sketch.construction_line.tangent_two": "Fælles tangent", + "cmd.sketch.construction_line.vertical": "Lodret hjælpelinje", + "cmd.sketch_arc3d": "3D-bue", "cmd.sketch_arc_center_point": "Bue med centrum", "cmd.sketch_arc_tangent": "Tangentbue", "cmd.sketch_arc_three_point": "Trepunktsbue", @@ -880,8 +1143,11 @@ "cmd.sketch_drag": "Træk skitsegeometri", "cmd.sketch_drag_end": "Afslut flytning af skitsen", "cmd.sketch_draw_dimensioned": "Tegn målsat skitsegeometri", + "cmd.sketch_drawing_bounds": "Tegningsstørrelse", "cmd.sketch_edit": "Redigér skitse", "cmd.sketch_edit_text": "Redigér tekst", + "cmd.sketch_equation_curve": "Ligningskurve", + "cmd.sketch_equation_curve_edit": "Redigér ligningskurve", "cmd.sketch_export_dxf": "Gem som DXF", "cmd.sketch_finish_auto_constrain": "Afslut med automatisk binding", "cmd.sketch_fit_curves_to_section": "Tilpas kurver til snit i mesh", @@ -924,9 +1190,12 @@ "cmd.sketch_spline_control_point": "Spline med kontrolpunkter", "cmd.sketch_spline_control_point_5": "Spline med kontrolpunkter (grad 5)", "cmd.sketch_spline_fit_point": "Spline gennem punkter", + "cmd.sketch_spline_insert_point": "Tilføj splinepunkt", "cmd.sketch_spline_reverse": "Vend splinens retning", "cmd.sketch_spun_profile": "Omdrejningsprofil", + "cmd.sketch_toggle_diameter": "Skift mellem radius og diameter", "cmd.sketch_toggle_driven": "Skift mellem styrende og afledt mål", + "cmd.sketch_transform": "Transformér", "cmd.sketch_trim_path": "Trim langs bane", "cmd.solid_align": "Justér", "cmd.solid_boundary_fill": "Afgrænset udfyldning", @@ -964,28 +1233,58 @@ "cmd.timeline_export_script": "Eksportér kommandoscript", "cmd.timeline_inspect": "Undersøg feature", "cmd.timeline_paste": "Indsæt features", + "cmd.timeline_play": "Afspil historik", "cmd.timeline_redefine": "Redigér feature", "cmd.timeline_rename": "Omdøb", "cmd.timeline_reorder": "Flyt feature i rækkefølgen", "cmd.timeline_roll_to": "Flyt tilbagerulningsmarkør", "cmd.timeline_rollback": "Flyt tilbagerulningsmarkør", + "cmd.timeline_stop": "Stop historik", "cmd.timeline_suppress": "Undertryk feature", + "cmd.view.render_browser": "Renderingsbrowser", + "cmd.view.render_dock": "Flyt renderingsdock", + "cmd.view.render_focus": "Fokusvisning af rendering", + "cmd.view.status_items": "Statuselementer i visningsområdet", "cmd.view_ambient_occlusion": "Ambient occlusion", + "cmd.view_angle_readout": "Visningsvinkler", "cmd.view_apply": "Gendan navngiven visning", + "cmd.view_back": "Visning bagfra", + "cmd.view_bottom": "Visning nedenfra", "cmd.view_delete": "Slet navngiven visning", + "cmd.view_fit": "Tilpas", + "cmd.view_front": "Visning forfra", "cmd.view_graphics_preset": "Grafikprofil", + "cmd.view_grid": "Gitter", + "cmd.view_ground_shadow": "Skygge på grundplan", + "cmd.view_home": "Hjemmevisning", + "cmd.view_isometric": "Isometrisk visning", + "cmd.view_left": "Visning fra venstre", "cmd.view_list": "Vis navngivne visninger", "cmd.view_navigate": "Navigér i visning", + "cmd.view_navigation_bar": "Navigationslinje", + "cmd.view_next_tab": "Næste fane", "cmd.view_normal_to": "Vinkelret på markering", + "cmd.view_previous_tab": "Forrige fane", "cmd.view_rename": "Omdøb navngiven visning", + "cmd.view_render_frame": "Hjælpelinjer til billedudsnit", + "cmd.view_right": "Visning fra højre", "cmd.view_save": "Ny navngiven visning", + "cmd.view_shots_strip": "Optagelsesstrimmel", + "cmd.view_spin": "Kontinuerlig rotation af visningen", + "cmd.view_tool": "Navigationsværktøj", + "cmd.view_top": "Visning ovenfra", + "cmd.view_visual_style": "Visuel stil", "color.copy": "Klik for at kopiere farveværdier", "color.edit": "Klik for at redigere farve", "color.gamma": "Viser farveværdier i 0-255 gammarum", + "color.hex": "Hex", + "color.hex_help": "Angiv #rrggbb eller #rrggbbaa; seks cifre bevarer opaciteten", + "color.hex_hint": "#rrggbb", "color.hue": "Farvetone", "color.linear": "Viser farveværdier i 0-1 lineært rum", "color.saturation_value": "Mætning og værdi", "color.selected": "Valgt farve", + "config.column_filter_empty": "Ingen matchende kolonner", "contact.components_count": "{count} komponenter", "contact.enabled_label": "aktiveret", "coupling.gear_1": "Tandhjul 1", @@ -1019,6 +1318,7 @@ "crash.report": "Rapportér…", "crash.unclean_explanation": "Registreret ved en senere opstart; det nøjagtige afslutningstidspunkt blev ikke noteret. Et systemnedlukning eller tvunget stop kan også give en uventet afslutning.", "delete.and_more": "… og {count} mere", + "diagnostic.empty_boolean_component": "der er intet emne at skære fra eller danne fællesvolumen med i komponenten '{component}'; værktøjet overlapper muligvis '{body}' i komponenten '{other}' — aktivér den komponent, eller vælg emnet som mål", "dialog.bodies_count": "Antal emner: {count}", "dialog.clearance_invalid": "afstanden skal være 0 eller større", "dialog.compute": "Beregn", @@ -1029,13 +1329,26 @@ "dialog.dimension_insert_hint": "Klik på et andet styrende mål for at indsætte dets navn", "dialog.dimension_name": "Mål: {name}", "dialog.edit_title": "REDIGÉR {title}", + "dialog.no_shown_body": "Intet vist emne at ændre: Vis et, eller vælg Nyt emne", "dialog.select": "↖ Vælg", "dialog.selected_count": "↖ Valgte: {count}", "dialog.selection_guidance": "Vælg et input for at ændre det. Klik på valgte objekter for at fjerne dem; Ctrl/Cmd-klik skifter også deres valgstatus. Gennemgå forhåndsvisningen, og tryk derefter på OK.", "dialog.selection_step": "{step} af {total}: Vælg {input}", "dialog.step": "Trin", + "document.changed_on_disk": "{name} er ændret på disken", + "document.changed_on_disk_detail": "{name} er blevet ændret af et andet program eller en anden computer, siden du åbnede eller sidst gemte det. Overskriv erstatter de ændringer med dine; Gem en kopi beholder begge.", + "document.copy_name": "Kopi af {name}", + "document.open_anyway": "Åbn alligevel", + "document.open_elsewhere": "{name} er allerede åbent", + "document.open_elsewhere_detail": "{name} er åbent i SolidCraft {app} på {who}, åbnet {when}. Hvis I begge gemmer, mister en af jer arbejde. Åbn det skrivebeskyttet for at se på det uden at gemme oven i det.", + "document.open_read_only": "Åbn skrivebeskyttet", + "document.opened_read_only": "Åbnet skrivebeskyttet: Gem beder om en ny fil", + "document.overwrite": "Overskriv", + "document.read_only_save": "Dette design blev åbnet skrivebeskyttet: Gem det som en ny fil", "document.save_changes": "Gem ændringer i {name}?", + "document.save_copy": "Gem en kopi…", "document.unsaved_changes": "{name} har ændringer, der ikke er gemt. De går tabt, hvis du ikke gemmer.", + "drive.rigid": "{joint} er et stivt led: Det holder sine dele fast, så der er intet at bevæge. For at lade dem bevæge sig skal du ændre ledtypen i Redigér led: Drejeled for at dreje, Glideled for at glide, Cylinderled for begge dele.", "drive.stopped_by_contact": "Stoppet ved {value}: {a} ville gå gennem {b}", "drive.stopped_by_loop": "Stoppet ved {value}: Løkken gennem {joint} kan ikke lukkes yderligere", "drive.unconnected": "Flytter intet: {part} er ikke forbundet til noget fastgjort", @@ -1051,9 +1364,13 @@ "dxf.progress.write": "Skriver DXF (5/5)", "dxf.tolerance": "Kurvetolerance", "dxf.tolerance_help": "Kun synlige emner og viste skitser. Kurver består af rette linjestykker. En mindre tolerance giver finere kurver og kræver mere tid og hukommelse.", + "each rail's curves must be in one sketch": "hver styrekurves kurver skal ligge i én skitse", + "feature computation ended without a result": "beregningen af featuren sluttede uden et resultat", + "file.open.drawing_filter": "Designs, CAD-filer, meshes og tegninger", "file_load.built": "Bygget indtil videre: {built} af {total} features.", "file_load.converting": "Konverterer solid {done} af {total}", "file_load.inserting": "Indsætter geometri", + "file_load.link_item": "{name} ({done} af {total})", "file_load.opening": "Åbner design", "file_load.preparing_thumbnail": "Forbereder miniaturebillede...", "file_load.preparing_view": "Forbereder visningen...", @@ -1063,25 +1380,88 @@ "file_load.rebuilding": "Genopbygger feature {done} af {total}", "file_load.recovering": "Genopretter design", "file_load.stopping": "Venter på, at den forrige handling stopper...", + "file_load.updating_links": "Opdaterer forbindelser", "file_load.waiting": "Venter på filhandling", "fits the face, e.g. M8": "passer til fladen, f.eks. M8", "from the profile": "fra profilen", "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.attributions": "Tilskrivninger (ATTRIBUTION.md)", "help.build": "Version {version} · commit {commit} · bygget {date}", + "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.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.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.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.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.mit": "MIT-licens", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Opret en uendelig vandret hjælpelinje gennem et punkt. Dens vandrette binding følger senere ændringer.", + "help.sketch.construction_line.parallel": "Vælg en ret skitselinje, og placér derefter en parallel uendelig hjælpelinje. Højreklik for at vælge en anden reference.", + "help.sketch.construction_line.perpendicular": "Vælg en ret skitselinje, og placér derefter en vinkelret uendelig hjælpelinje. Højreklik for at vælge en anden reference.", + "help.sketch.construction_line.tangent_at": "Opret en tangent i et punkt på en cirkel eller cirkelbue eller i endepunktet af en åben spline eller et keglesnit. Vælg kurven og derefter dens berøringspunkt.", + "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.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.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.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.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.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.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.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.", + "help.solid.loft": "Loft danner et glat massivt legeme mellem to eller flere skitseprofiler eller fra en profil til et punkt. Brug den til overgange, tragte, håndtag og alle former, hvis tværsnit ændres langs længden.", + "help.solid.mirror": "Spejling laver en spejlet kopi af de valgte emner eller features over et plan og kan forene den med originalen. Brug den til symmetriske dele, så du kun modellerer den ene halvdel og får begge.", + "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.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.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.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.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.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.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.", + "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", + "hole_presets.delete": "Slet", + "hole_presets.delete_prompt": "Slet denne personlige forudindstilling? Eksisterende huller ændres ikke.", + "hole_presets.full": "Det personlige bibliotek indeholder allerede 64 forudindstillinger.", + "hole_presets.invalid_name": "Angiv et kort navn uden linjeskift eller kontroltegn.", + "hole_presets.invalid_parameters": "Udfyld hulparametrene med gyldige udtryk af rimelig længde.", + "hole_presets.missing": "Den valgte forudindstilling er ikke længere tilgængelig.", + "hole_presets.name": "Navn på forudindstilling", + "hole_presets.name_exists": "Der findes allerede en forudindstilling med dette navn. Brug et andet navn.", + "hole_presets.name_hint": "Et entydigt navn", + "hole_presets.overwrite": "Gem ændringer i den valgte forudindstilling", + "hole_presets.preset": "Forudindstilling", + "hole_presets.recall": "Hent", + "hole_presets.rename": "Omdøb", + "hole_presets.save": "Gem som ny", + "hole_presets.scope": "Gemt i din profil og tilgængelig i alle projekter.", + "hole_presets.select": "Vælg en forudindstilling", + "hole_presets.title": "Personlige forudindstillinger", + "hole_presets.unsupported_options": "Denne forudindstilling bruger gevindindstillinger, som denne version ikke kan hente.", + "hole_presets.unsupported_overwrite": "Denne forudindstilling indeholder indstillinger, der ikke kan overskrives sikkert.", "home.back": "Tilbage til {name}", "home.crashes": "Nedbrudsrapporter", "home.design_workspace": "Design", @@ -1146,6 +1526,9 @@ "install.steps_intro": "Safari installerer webapps via sine egne menuer:", "install.title": "Installér SolidCraft", "issue.body_label": "Rapporttekst", + "issue.note_changed_while_waiting": "Indstillingerne eller modellen blev ændret, mens du ventede på forhåndsvisningen. Tag skærmbilledet igen for at opdatere de tekniske oplysninger til den aktuelle visning.", + "issue.note_details_kept": "Skærmbilledet er optaget lokalt. Dine redigerede tekniske oplysninger er bevaret: Kontrollér, at de stadig passer til den aktuelle visning.", + "issue.note_details_updated": "De tekniske oplysninger er opdateret, så de passer til den aktuelle visning. Skærmbilledet er optaget lokalt. Gennemgå det, før du vedhæfter det på GitHub.", "issue.note_image_copied": "Indsæt billedet i GitHub-kladden, eller gem PNG-filen, hvis billedkopiering ikke er tilgængelig.", "issue.note_opened": "Gennemgå GitHub-kladden, og vedhæft skærmbilledet. GitHubs knap Submit new issue udgiver den.", "issue.note_opened_copied": "Rapport kopieret. Indsæt den i GitHub-kladden, vedhæft skærmbilledet, og klik derefter på Submit new issue på GitHub.", @@ -1157,9 +1540,28 @@ "joint.from_bodies_hint": "Hvert valgt emne flyttes til sin egen nye komponent og bliver på sin plads.", "joint.one_body": "Begge valg er på {body}. Vælg det andet på et andet emne.", "joint.one_component": "Disse emner tilhører samme komponent: {bodies}. Et led bevæger én komponent i forhold til en anden, så hvert emne skal have sin egen komponent.", + "joint.rigid_group_one_component": "Disse {count} emner er alle i én komponent (eller i selve designet): Brug først {command}.", + "joint.rigid_group_select_components": "Vælg to eller flere komponenter.", + "joint_move.blocked_hint": "Kontrollér ledtypen (f.eks. Stiv), fastgørelse, bevægelsesgrænser for led og parameterstyrede værdier.", + "joint_move.blocked_title": "Kan ikke flytte dette emne", + "joint_move.coarse": "Rotationstrin nær aksen (°)", + "joint_move.distance": "Afstand fra start", + "joint_move.done": "Færdig", + "joint_move.fine": "Rotationstrin langt fra aksen (°)", + "joint_move.fixed": "Dette emne har ingen bevægelig ledakse.", + "joint_move.help": "Flyt — træk i en pil eller en ring. Esc annullerer det aktuelle træk.", + "joint_move.legacy_hint": "Slå fra for at bruge det tidligere Flyt-panel.", + "joint_move.precision": "Træk uden for den stiplede hjælpelinje for finere rotation.", + "joint_move.rotation": "Rotation fra start", + "joint_move.select": "Vælg en flade eller ét emne i samlingen, og vælg derefter Flyt.", + "joint_move.settings": "Flyt i samling", + "joint_move.step": "Trin", + "joint_move.use_handles": "Brug håndtag til ledbevægelse ved Flyt i samling", "keymap.command_id": "Kommando-ID", "keymap.empty": "Ingen matchende kommandoer", "keymap.use_anyway": "Brug {key} her alligevel", + "label.move_to_left_side": "Flyt til venstre side", + "label.move_to_right_side": "Flyt til højre side", "manage.appearance_hint": "Vælg et udseende til markeringen, eller træk en farveprøve over på et emne eller en flade.", "manage.assign_hint": "Vælg emner, og vælg deres plastdesignregel.", "manage.config_hint": "Redigér konfigurationsrækker, kolonner og regler, og aktivér eller forhåndsvis derefter en række.", @@ -1167,8 +1569,57 @@ "manage.material_hint": "Vælg emner, og vælg deres fysiske materiale.", "manage.parameters_hint": "Redigér udtryk og enheder. Ændringer anvendes ved Enter eller når du forlader et felt.", "manage.rules_hint": "Vælg en regel at redigere, eller opret en ny regel. Bekræft for at gemme reglen.", + "material.editor.add_property": "Tilføj egenskab", + "material.editor.add_source": "Tilføj kilde", + "material.editor.assigned_density": "Tildelt densitet", + "material.editor.axes": "Materialeakser", + "material.editor.axes_numeric": "1 / 2 / 3", + "material.editor.axes_wood": "L / R / T", + "material.editor.copy_name": "Kopi af {name}", + "material.editor.density_range": "Den tildelte densitet skal være over 0 og højst 1000 g/cm³.", + "material.editor.designation": "Betegnelse", + "material.editor.duplicate": "Duplikér", + "material.editor.engineering_card": "Teknisk egenskabskort", + "material.editor.family": "Materialefamilie", + "material.editor.glass": "Glas", + "material.editor.illustrative": "Illustrativ", + "material.editor.invalid_property": "Egenskabstypen understøttes ikke.", + "material.editor.isotropic": "Isotrop", + "material.editor.measurement": "Måling", + "material.editor.metal": "Metal", + "material.editor.moisture": "Fugtindhold, tør basis", + "material.editor.name_required": "Angiv et navn på 1–128 tegn.", + "material.editor.no_draft": "Der er ikke åbnet noget materialeudkast.", + "material.editor.number_required": "Angiv en endelig numerisk værdi.", + "material.editor.orthotropic": "Ortotrop", + "material.editor.other": "Andet", + "material.editor.polymer": "Polymer", + "material.editor.preserved_appearance": "Bevaret udseende", + "material.editor.public_reference": "Offentlig reference", + "material.editor.reference": "Reference", + "material.editor.save_or_discard": "Gem eller kassér materialeudkastet, før du tildeler et materiale.", + "material.editor.saved": "Materialet er gemt. Tildelinger til emner er uændrede.", + "material.editor.scope": "Gem gemmer materialet uden at tildele det til emner. Tomme egenskaber og betingelser forbliver ukendte. Kortets densitet ændrer ikke den tildelte densitet eller massen.", + "material.editor.source_id": "Kilde-id", + "material.editor.source_kind": "Kildetype", + "material.editor.source_title": "Kildetitel", + "material.editor.sources": "Kilder", + "material.editor.stale": "Dette materiale blev ændret, mens udkastet var åbent. Kassér udkastet, og åbn det igen, før du gemmer.", + "material.editor.state": "Materialetilstand", + "material.editor.strain_rate": "Tøjningshastighed", + "material.editor.symmetry": "Elastisk symmetri", + "material.editor.temperature": "Temperatur", + "material.editor.unknown_card": "De tekniske egenskaber er ukendte. Gem beholder muligvis kun den tildelte densitet og det eksisterende udseende.", + "material.editor.user_supplied": "Angivet af brugeren", + "material.editor.wood": "Træ", + "material.selection.choose": "Vælg et materiale", + "material.selection.mixed": "Forskellige materialer", "measure.at": "Ved", "measure.item": "Element {number}", + "measure.quick_copied": "Måling kopieret", + "measure.quick_copy": "Klik for at kopiere målingen", + "measure.quick_edges": "{count} kanter", + "measure.quick_faces": "{count} flader", "messages.ago_h": "For {n} timer siden", "messages.ago_min": "For {n} min. siden", "messages.ago_s": "For {n} sek. siden", @@ -1195,11 +1646,17 @@ "nav.blender.help": "Træk med midterste museknap roterer visningen, Shift+midterste panorerer, Ctrl+midterste zoomer; træk med højre museknap påvirker ikke kameraet", "nav.free_orbit.help": "Musen og 3D-musen roterer gennem begge poler og bevarer rulning. Snap til akser med Shift er slået fra.", "nav.free_orbit.label": "Fri rotation af visningen", + "nav.free_orbit.snap_help": "Musen og 3D-musen roterer gennem begge poler og bevarer rulning. Hold Shift nede under et rotationstræk for at snappe visningsretningen og rulningen.", + "nav.free_orbit_upright.help": "Sidelæns musetræk drejer modellen om verdens lodrette akse. Træk op og ned vipper modellen over toppen uden begrænsning.", + "nav.free_orbit_upright.label": "Drej om den lodrette akse", "nav.fusion.help": "Træk med midterste museknap panorerer, Shift+midterste roterer visningen, træk med højre roterer visningen", "nav.hfss.help": "Alt+træk med venstre museknap panorerer, Shift+Alt+venstre zoomer, Ctrl+træk med venstre fra et tomt område roterer visningen", "nav.inventor.help": "Træk med midterste museknap panorerer, Shift+midterste roterer visningen, Ctrl+midterste zoomer, træk med højre roterer visningen", "nav.maya.help": "Alt+træk med venstre museknap roterer visningen, Alt+midterste panorerer, Alt+højre zoomer; træk med midterste museknap panorerer også", "nav.openscad.help": "Træk med venstre museknap i et tomt område roterer visningen, Shift+venstre markerer med en ramme, træk i skitsegeometri redigerer den; træk med højre eller midterste museknap panorerer, Shift+højre eller Shift+midterste zoomer", + "nav.perspective.sketch": "Skitseredigering bruger ortografisk visning. Afslut skitsen for at skifte til perspektiv.", + "nav.right_drag_orbits.help": "Slået fra: Træk med højre museknap påvirker ikke kameraet, og højre knap åbner kun genvejsmenuen. Hvis du vælger SolidWorks-musetildelingen, slås dette fra.", + "nav.right_drag_orbits.label": "Træk med højre museknap roterer visningen", "nav.solidworks.help": "Træk med midterste museknap roterer visningen, Ctrl+midterste panorerer, Shift+midterste zoomer, træk med højre roterer visningen", "nav.tinkercad.help": "Træk med højre museknap roterer visningen, træk med midterste eller Shift+højre panorerer", "nav.touchpad.help": "Hold Shift nede, og bevæg markøren for at panorere; hold Alt/Option nede, og bevæg den for at rotere visningen. Ingen museknap behøver at være trykket ned. Lodret rulning zoomer.", @@ -1208,36 +1665,194 @@ "nav.touchpad.pan": "Panorér (hold Shift nede og bevæg markøren; ingen museknap)", "nav.touchpad.scroll": "Pegefelt bruger lodret rulning til zoom. Din indstilling for panorering med to fingre bevares for andre forudindstillinger.", "nav.touchpad.zoom": "Zoom (lodret rulning eller knibebevægelse)", + "navbar.agent_chat": "Assistentens chatpanel", + "navbar.agent_cursor": "Vis agentmarkør", + "navbar.ambient_occlusion": "Ambient occlusion", + "navbar.assistant": "Assistent", + "navbar.custom.add_slot": "Tilføj {slot}", + "navbar.custom.always": "Altid synlig", + "navbar.custom.available": "{available} · træk til linjen, eller tryk på Tilføj", + "navbar.custom.buttons": "Knapper", + "navbar.custom.compact": "Kompakt", + "navbar.custom.count": "{count} af 16 pladser", + "navbar.custom.customise": "Tilpas navigationslinje…", + "navbar.custom.design": "Design", + "navbar.custom.duplicate": "Denne plads findes allerede på linjen.", + "navbar.custom.edge": "Kant", + "navbar.custom.edge_bottom": "Nederst", + "navbar.custom.edge_top": "Øverst", + "navbar.custom.fade": "Udtoning", + "navbar.custom.full": "Fjern en plads for at tilføje en ny.", + "navbar.custom.hidden_design": "Skjult i Design", + "navbar.custom.hidden_render": "Skjult i Rendering", + "navbar.custom.hide": "Skjul navigationslinjen", + "navbar.custom.hide_idle": "Skjul, indtil musen holdes over", + "navbar.custom.icon_only": "Kun ikon", + "navbar.custom.icon_value": "Ikon og værdi", + "navbar.custom.icons": "Ikoner", + "navbar.custom.idle": "Ved inaktivitet", + "navbar.custom.intro": "Vælg, hvilke knapper der skal være på linjen nederst i 3D-visningen, og i hvilken rækkefølge. Træk en plads for at flytte den, eller træk den væk fra linjen for at fjerne den.", + "navbar.custom.labels": "Ikoner og tekst", + "navbar.custom.left": "Flyt til venstre", + "navbar.custom.position": "Placering", + "navbar.custom.position_centre": "Midt", + "navbar.custom.position_left": "Venstre", + "navbar.custom.position_right": "Højre", + "navbar.custom.preview_as": "Forhåndsvis som", + "navbar.custom.regular": "Normal", + "navbar.custom.remove": "Fjern", + "navbar.custom.remove_bar": "Fjern fra linjen", + "navbar.custom.render": "Rendering", + "navbar.custom.reset": "Nulstil navigationslinje", + "navbar.custom.reset_default": "Nulstil til standard", + "navbar.custom.right": "Flyt til højre", + "navbar.custom.select": "Ingen plads er valgt. Vælg en plads på linjen for at flytte eller fjerne den eller ændre dens udseende.", + "navbar.custom.separators_full": "Linjen har allerede fire skillelinjer.", + "navbar.custom.show": "Vis navigationslinjen", + "navbar.custom.shows": "Viser", + "navbar.custom.size": "Størrelse", + "navbar.custom.stale": "Linjen er ændret. Vælg pladsen igen.", + "navbar.custom.title": "Navigationslinje", + "navbar.custom.unknown": "Denne plads er ikke tilgængelig.", + "navbar.description.ambient_occlusion": "Mørkner folder og hjørner for at give dybde.", + "navbar.description.display": "Samler visningsindstillingerne: grafikprofil, gitter, skygge på grundplan, ambient occlusion og projektion.", + "navbar.description.fit": "Tilpasser visningen til markeringen eller til hele modellen, hvis intet er markeret.", + "navbar.description.grid": "Viser eller skjuler gitteret på grundplanet.", + "navbar.description.ground_shadow": "Viser eller skjuler skyggen under modellen.", + "navbar.description.home": "Vender tilbage til den isometriske visning af hele modellen.", + "navbar.description.look_at": "Drejer visningen, så den vender mod den valgte flade, det valgte plan eller den åbne skitse.", + "navbar.description.orbit": "Drejer visningen rundt om modellen.", + "navbar.description.pan": "Panorerer visningen sidelæns, op eller ned.", + "navbar.description.projection": "Skifter mellem ortografisk visning og perspektivvisning.", + "navbar.description.separator": "En tynd streg, der grupperer nabopladser.", + "navbar.description.spin": "Lader visningen dreje rundt, indtil du klikker eller trykker på en tast.", + "navbar.description.visual_style": "Skifter, hvordan modellen tegnes: skyggelagt, som trådmodel eller med skjulte kanter.", + "navbar.description.zoom": "Flytter visningen tættere på modellen eller længere væk fra den.", + "navbar.display": "Visning", + "navbar.fit": "Tilpas", + "navbar.graphics": "Grafik", + "navbar.grid": "Gitter", + "navbar.ground_shadow": "Skygge på grundplan", + "navbar.home": "Hjem", + "navbar.later": "Senere", + "navbar.look_at": "Se på", + "navbar.more": "Mere", + "navbar.orbit": "Rotér visning", + "navbar.orthographic": "Ortografisk", + "navbar.pan": "Panorér", + "navbar.perspective": "Perspektiv", + "navbar.preset": "Forudindstilling", + "navbar.projection": "Projektion", + "navbar.projection_heading": "Projektion", + "navbar.separator": "Skillelinje", + "navbar.show": "Vis", + "navbar.spin": "Drej", + "navbar.style.shaded": "Skyggelagt", + "navbar.style.shaded_with_edges": "Skyggelagt med kanter", + "navbar.style.shaded_with_hidden_edges": "Skyggelagt med skjulte kanter", + "navbar.style.wireframe": "Trådmodel", + "navbar.translucent": "Halvgennemsigtig", + "navbar.value_tip": "{name} · {value}", + "navbar.visual_style": "Visuel stil", + "navbar.zoom": "Zoom", "needs two joints": "kræver to led", + "network.allow_once": "Tillad én gang", + "network.always_allow": "Tillad altid denne server", + "network.prompt_detail": "Dette design linker til en fil på netværksplaceringen {host}. Når filen åbnes, oprettes der forbindelse til den computer, og dit Windows-login kan blive sendt til den. Tillad kun servere, du har tillid til.", + "network.prompt_title": "Netværksplacering", "new configuration": "ny konfiguration", "new parameter": "ny parameter", "no hole": "intet hul", "no interference": "intet overlap", "no joint that moves yet": "endnu intet bevægeligt led", + "no motion study yet": "ingen bevægelsesanalyse endnu", "no picture": "intet billede", + "no such joint": "der findes intet sådant led", "none": "ingen", + "not a move": "ikke en flytning", + "not a number": "ikke et tal", + "open a sketch first": "åbn en skitse først", "parts.automatic_name": "automatisk", + "parts.dimension.across_flats": "Nøglevidde", + "parts.dimension.bore": "Boring", "parts.dimension.d": "Nominel diameter", + "parts.dimension.head_diameter": "Hovedets diameter", + "parts.dimension.head_height": "Hovedets højde", "parts.dimension.height": "Højde", + "parts.dimension.inner": "Indvendig diameter", + "parts.dimension.outer": "Udvendig diameter", "parts.dimension.pitch": "Stigning", + "parts.dimension.socket": "Størrelse på indvendig sekskant", + "parts.dimension.socket_depth": "Dybde af indvendig sekskant", "parts.dimension.thickness": "Tykkelse", "parts.dimension.width": "Bredde", + "parts.family.bearing": "Rillekuglelejer (omrids)", + "parts.family.button_head_cap_screw": "Rundhovedet skrue med indvendig sekskant", + "parts.family.dowel_pin": "Cylindrisk stift (dyvel)", + "parts.family.hex_bolt": "Sekskanthovedet bolt / skrue", + "parts.family.hex_nut": "Sekskantmøtrik", + "parts.family.set_screw": "Stilleskrue med indvendig sekskant, flad spids", + "parts.family.socket_head_cap_screw": "Cylinderskrue med indvendig sekskant", + "parts.family.washer": "Almindelig skive", + "parts.holes": "Huller (valgfrit)", + "parts.placement_hint": "vælg et eller flere huller for at sætte en del i hvert, eller tryk OK for at placere den ved nulpunktet", "pattern.alignment_2": "Justering 2", "pattern.alignment_hint": "Antal omfatter originalen. Centrér fordeler kopierne ligeligt; Vend vælger siden med den ekstra kopi ved et lige samlet antal.", "pattern.direction_1": "Retning 1", "pattern.direction_2": "Retning 2", "pattern.flip_direction": "Vend retning", + "perf.click_for_details": "Klik for detaljer: billedtidsgraf, histogram og det, GPU'en har tegnet", + "perf.draw_calls": "Tegnekald", + "perf.line_segments": "Linjesegmenter", + "perf.low_1": "Laveste 1 %", + "perf.renderer": "Renderingsmotor", + "perf.scene_build": "Opbygning af scene", + "perf.triangles": "Trekanter", + "perf.ui_time": "UI-tid", + "perf.viewport": "Visningsområde", + "perf.worst": "værst", + "pick a body or some of its faces": "vælg et emne eller nogle af dets flader", "pick a hole to seat it, or OK places it at the origin": "vælg et hul til placeringen, eller tryk OK for at placere ved nulpunktet", + "pick a planar face": "vælg en plan flade", + "pick a plane or a face as the tool": "vælg et plan eller en flade som værktøj", + "pick a size": "vælg en størrelse", + "pick edges of a body": "vælg kanter på et emne", + "pick edges of one body": "vælg kanter på ét emne", + "pick edges of the holes to remove": "vælg kanter på de huller, der skal fjernes", + "pick faces of one body": "vælg flader på ét emne", + "pick faces to offset": "vælg flader, der skal forskydes", + "pick lines of one sketch": "vælg linjer i én skitse", "pick one or two vertices, edges or faces": "vælg et eller to hjørnepunkter, kanter eller flader", + "pick one plane or face as the tool, or sketch curves": "vælg ét plan eller én flade som værktøj, eller skitsekurver", + "pick sections in two or more sketches": "vælg tværsnit i to eller flere skitser", + "pick sketch curves or edges": "vælg skitsekurver eller kanter", + "pick the face to untrim": "vælg den flade, hvis beskæring skal fjernes", + "pick the path": "vælg banen", + "pick what to keep": "vælg, hvad der skal beholdes", + "pick what to keep of the other surface": "vælg, hvad der skal beholdes af den anden overflade", "prefs.add_item": "Tilføj elementet", "prefs.alt_frees_manipulators": "Alt / Option frigør også manipulatorpilene", + "prefs.anti_aliasing_amd_dx12": "Kantudjævning bruger 1×, fordi startkontrollen ikke tegnede noget med multisampling og bestod ved 1×. Du kan vælge en anden indstilling ovenfor. Genstart for at anvende den, hvis kontrollen består.", "prefs.anti_aliasing_note": "Udjævner takkede kanter.", "prefs.applies_on_restart": "Træder i kraft efter genstart", "prefs.assistant": "Assistent", + "prefs.auto_operation": "Vælg operationen automatisk", + "prefs.auto_operation_note": "Featurevinduer vælger Foren, Nyt emne eller Skær fra ud fra det, det nye materiale berører, indtil du selv vælger en. Slået fra: De beholder den operation, de åbnes med.", "prefs.axes": "Akser", "prefs.axis": "Akse", + "prefs.bodies_folders": "Emnemapper", + "prefs.bodies_folders_always": "Altid", + "prefs.bodies_folders_never": "Aldrig", + "prefs.bodies_folders_several": "Kun ved flere emner", "prefs.bottom": "Bund", + "prefs.bottom_centre": "Nederst i midten", + "prefs.bottom_right": "Nederst til højre", "prefs.browser": "Modelbrowser", + "prefs.browser_eye_left": "Øje ved siden af foldepilen", + "prefs.browser_eye_left_note": "Rækkerne i modeltræet viser deres øje til vis/skjul ved siden af foldepilen som før i stedet for i en kolonne yderst til højre i rækkerne.", + "prefs.browser_reveal_on_hover": "Vis, når musen holdes over", + "prefs.browser_show_note": "Vist holder modeltræet på skærmen. Vis, når musen holdes over skjuler det, indtil markøren kommer hen over dets plads eller den tynde fane ved dets venstre kant. Det forbliver åbent, mens markøren er inde i det, eller en menu fra det er åben.", + "prefs.browser_shown": "Vist", "prefs.built_in": "Indbygget", "prefs.button_left": "Træk med venstre museknap", "prefs.button_middle": "Træk med midterste museknap", @@ -1259,6 +1874,11 @@ "prefs.design": "Standardindstillinger for design", "prefs.design_note": "Udgangspunktet for nye design. Åbne design beholder deres egne indstillinger.", "prefs.devices": "3D-mus", + "prefs.dialog_buttons": "Dialogknapper", + "prefs.dialogs": "Dialoger", + "prefs.dialogs_edge": "Ved visningens kant", + "prefs.dialogs_left_column": "I venstre kolonne", + "prefs.dialogs_note": "Hvor kommandodialoger åbnes: Fastgjort ved visningens kant eller i venstre kolonne i stedet for modeltræet og historikken, som vender tilbage, som de var, når dialogen lukkes.", "prefs.display": "Visning", "prefs.edges": "Kanter og skygning", "prefs.extend": "Udvid", @@ -1274,12 +1894,27 @@ "prefs.gesture_pan": "Panorér", "prefs.gesture_zoom": "Zoom", "prefs.graphics": "Grafik", + "prefs.graphics_card": "Grafikkort", + "prefs.graphics_card_auto": "Automatisk", + "prefs.graphics_card_missing": "Det gemte grafikkort er ikke tilgængeligt. Automatisk bruges i stedet.", + "prefs.graphics_card_restart": "Træder i kraft efter genstart af SolidCraft. WGPU_ADAPTER_NAME tilsidesætter dette valg.", "prefs.graphics_note": "Afvej hastighed mod udjævning. Hvert valg viser sin virkning på et eksempel.", "prefs.graphics_presets_note": "Forudindstillinger angiver de nedenstående valg samlet.", + "prefs.hide_constraint_glyphs_zoomed_out": "Skjul bindingssymboler, når der zoomes ud", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Bindingssymboler forsvinder fra visningen, når skitsen er zoomet så langt ud, at de ville fylde for meget. Slået fra: De bevarer en fast størrelse på skærmen ved alle zoomniveauer.", "prefs.history": "Historik", + "prefs.history_origin_bodies": "Vis nulpunkt og emner øverst", + "prefs.history_origin_bodies_note": "Historikken til venstre eller under modeltræet starter med den aktive komponents mappe Emner og Nulpunktet med deres øjne og menuer som i modeltræet. Historikken nederst er uændret.", + "prefs.history_play_button": "Knappen Afspil historik i historikkens kontroller", + "prefs.history_play_button_note": "Placerer Afspil historik mellem Gå ét trin tilbage og Gå ét trin frem i historikkens kontroller. Du kan også højreklikke på kontrollerne for at vise eller skjule knappen.", + "prefs.history_sketches": "Skitser", + "prefs.history_sketches_in_order": "I rækkefølge", + "prefs.history_sketches_note": "Når Skitser er sat til Under deres feature, viser historikken til venstre hver skitse ét niveau inde under den første feature, der er bygget på den, så snart den er bred nok til navne. Tidslinjen nederst bevarer altid oprettelsesrækkefølgen.", + "prefs.history_sketches_under_feature": "Under deres feature", "prefs.hold_stop_snapping": "Hold nede for at slå snapping fra", "prefs.icons_only": "Kun ikoner", "prefs.imported_file": "Importeret fil", + "prefs.in_browser": "I modeltræet", "prefs.input": "Input", "prefs.interface": "Brugergrænseflade", "prefs.interface_language": "Sprog i brugergrænsefladen", @@ -1290,8 +1925,14 @@ "prefs.layout_note": "Kompakt fastgør panelerne til vinduets kanter. Gennemsigtighed i modelbrowseren er tilgængelig i Flydende. Skift assistentens side på siden Assistent.", "prefs.left": "Venstre", "prefs.left_click": "Venstreklik", + "prefs.line_drag_draws_one": "Tryk og træk tegner én linje", + "prefs.line_drag_draws_one_note": "I værktøjet Linje tegner tryk, træk og slip én linje fra trykket til slippet, og linjen slutter dér. Klik tegner stadig en kæde. Slået fra: Et træk placerer et punkt, hvor det slippes, ligesom et klik gør.", + "prefs.linked_designs": "Linkede designs", "prefs.live_input": "Løbende input", "prefs.live_preview": "Løbende forhåndsvisning", + "prefs.logo": "Logo", + "prefs.logo_accent": "Accentfarve", + "prefs.logo_theme": "Temaets farver", "prefs.max_file_size": "Største fil, der kan åbnes", "prefs.max_file_size_design": "Designfiler", "prefs.max_file_size_flag": "Denne kørsel blev startet med --max-file-size {size}, som gælder, indtil SolidCraft lukkes. Disse indstillinger gælder ved start uden denne valgmulighed.", @@ -1299,6 +1940,9 @@ "prefs.max_file_size_step": "STEP- og importfiler", "prefs.max_file_size_web": "Webappen åbner STEP- og importfiler op til 512 MB og design op til 256 MB: en browserfane kan ikke rumme større filer. Grænserne kan hæves i skrivebordsappen.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Store ikoner", + "prefs.menu_list": "Liste", + "prefs.menus": "Menuer", "prefs.minutes": "minutter", "prefs.minutes_suffix": " min", "prefs.mouse": "Mus og pegefelt", @@ -1306,37 +1950,106 @@ "prefs.mouse_mapping": "Musetildeling", "prefs.navigation": "Navigation", "prefs.navigation_note": "Hvordan musen, pegefeltet og 3D-musen styrer visningen.", + "prefs.network_host_remove": "Fjern", + "prefs.network_locations": "Tillad netværksplaceringer i designs", + "prefs.network_locations_note": "Slået fra: Et designs linkede filer på en anden computer (\\\\server\\share) åbnes kun fra de servere, der er angivet her, eller efter at du har tilladt en, når du bliver spurgt.", + "prefs.network_locations_section": "Netværksplaceringer", "prefs.not_on_gpu": "ikke på denne GPU", "prefs.on": "Til", "prefs.opaque": "Uigennemsigtig", "prefs.orbit_snap": "Hold Shift nede under rotation af visningen for at snappe til en akse.", "prefs.orientation": "Orientering", + "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.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", + "prefs.placement": "Placering", + "prefs.prefer_free_sketch_points": "Foretræk flytbare skitsepunkter, når punkter overlapper", "prefs.preview_choices": "Forhåndsvisning af dine valg", + "prefs.print_face_highlight": "Fremhævning af printflade", + "prefs.print_face_highlight_tip": "Hvor kraftigt et legemes printflade farves og omrids i visningen. 0 % viser ingen fremhævning.", + "prefs.profile_active": "Aktiv profil", + "prefs.profile_applied": "Profil anvendt.", + "prefs.profile_changed": "Indstillingerne er ændret, siden profilen blev gemt. Gem beholder dem i profilen.", + "prefs.profile_deleted": "Profil slettet.", + "prefs.profile_export": "Eksportér…", + "prefs.profile_exported": "Profil eksporteret.", + "prefs.profile_imported": "Profil importeret. Vælg den under Aktiv profil for at skifte til den.", + "prefs.profile_name_hint": "Profilnavn, f.eks. Bærbar", + "prefs.profile_rename": "Omdøb", + "prefs.profile_renamed": "Profil omdøbt.", + "prefs.profile_save": "Gem", + "prefs.profile_saved": "De aktuelle indstillinger er gemt i profilen.", + "prefs.profile_skipped": "Profil anvendt. {n} indstilling(er) beholdt deres nuværende værdi (se loggen).", + "prefs.profiles": "Indstillingsprofiler", + "prefs.profiles_note": "Genveje, værktøjslinjen, navigation, den radiale menu og indstillinger for brugerfladen gemmes samlet under et navn, så du kan skifte mellem dem med ét klik.", + "prefs.project_options_dialog": "Dialog med projektionsindstillinger", + "prefs.project_options_dialog_note": "Slået til: Vælg Geometri eller Emner og Projektionslink, før du projicerer. Slået fra: Behold den tidligere arbejdsgang med direkte valg.", "prefs.quality_default": "standard", "prefs.quality_fastest": "hurtigst", "prefs.quality_slowest": "langsomst", "prefs.quality_smoother": "mere udjævnet", + "prefs.quick_measure": "Hurtigmåling i visningens hjørne", + "prefs.quick_measure_note": "Markeringens størrelse i visningens nederste højre hjørne: en kants længde eller Ø og R for en rund kant, en flades areal, et punkts position, afstand og vinkel mellem to elementer eller den samlede værdi for flere kanter eller flader. Et klik kopierer den.", + "prefs.render_previous_geometry_budget": "Brug det tidligere geometribudget til forhåndsvisning af rendering", + "prefs.render_previous_geometry_budget_help": "Gendan den tidligere faste geometrigrænse (512 MiB på 64-bit-computere, 128 MiB andre steder) i stedet for det enhedsbaserede budget.", "prefs.reports_saved": "{n} gemt", + "prefs.ribbon_reveal": "Vis søgeresultater på båndet", + "prefs.ribbon_reveal_note": "Når du holder musen over et resultat i Søg efter kommandoer, vises det, hvor kommandoen sidder på båndet: dens fane, dens knap eller dens række i et panels rullemenu. En kommando, der startes fra søgningen, blinker dér, og en kørende kommando fra en rullemenu markerer sit panel.", "prefs.right_click": "Højreklik", "prefs.saving": "Automatisk lagring og gendannelse", "prefs.scale_note": "Også Ctrl + / Ctrl − i vinduet. Knapper er 28 px ved 100%.", "prefs.scale_text": "Skalering og tekst", + "prefs.search": "Søg i indstillinger", + "prefs.search_empty": "Ingen matchende indstillinger", "prefs.select": "Vælg", "prefs.select_one_item": "Vælg ét element", + "prefs.select_other_hidden": "Vælg skjult geometri med Vælg andet", + "prefs.select_other_hidden_help": "Slået til: Medtag skjulte emner og skitser samt kanter, der er skjult bag andet, nær markøren. Slået fra: Brug den oprindelige liste over synlig geometri under markøren.", "prefs.selecting": "Markering", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift tilføjer kun; Ctrl / Cmd skifter markeringen", "prefs.shift_add_note": "Til: Shift tilføjer, Ctrl / Cmd skifter markeringen. Fra (standard): Begge skifter markeringen.", "prefs.shortcuts": "Genveje", + "prefs.single_surface_analysis": "Brug kun én overfladeanalyse ad gangen", + "prefs.single_surface_analysis_help": "Når den er slået fra, kan overfladeskygning og Kontinuitet være slået til samtidig. Hvis du slår den til, beholdes den sidst startede eller ændrede analyse.", + "prefs.sketch_align_points": "Justér efter andre skitsepunkter under tegning", + "prefs.sketch_align_points_note": "Under tegning justeres et punkt samtidig vandret efter ét skitsepunkt og lodret efter et andet med en punkteret hjælpelinje, og klikket beholder begge justeringer. Slået fra: Kun efter det forrige punkt, én akse ad gangen.", + "prefs.sketch_conflict_colour": "Geometri i konflikt i konfliktfarven", + "prefs.sketch_conflict_colour_note": "Mens en skitses bindinger er i konflikt, tegnes de kurver og punkter, som en fejlende binding holder, også i konfliktfarven og ikke kun dens symbol eller mål. Slået fra: De beholder deres sædvanlige farver.", + "prefs.sketch_construction_lines": "Udvidede værktøjer til hjælpelinjer", + "prefs.sketch_exit_view": "Når en skitse afsluttes", + "prefs.sketch_exit_view.keep": "Behold den aktuelle visning", + "prefs.sketch_exit_view.previous": "Vend tilbage til den forrige visning", + "prefs.sketch_exit_view.unless_rotated": "Vend tilbage, medmindre visningen er roteret", + "prefs.sketch_exit_view_note": "Hvor visningen går hen efter Afslut skitse, Annullér eller et dobbelt tryk på Esc.", + "prefs.sketch_open_look_at": "Vend visningen mod en skitse, når den åbnes", + "prefs.sketch_open_look_at_note": "Slået fra: Når en skitse oprettes eller redigeres, forbliver visningen, som den er. Se på vender visningen mod skitsen, når du ønsker det.", + "prefs.sketch_recent_snap": "Automatisk snap til nylige centre og midtpunkter", + "prefs.sketch_recent_snap_note": "Under tegning snappes markøren dér, hvor hjælpelinjerne fra de to seneste cirkelcentre eller midtpunkter på rette kanter krydser hinanden. Slået fra: Hjælpelinjerne er kun visuelle, og markøren flytter sig ikke.", "prefs.sketch_selection": "Skitse og markering", "prefs.sketch_selection_heading": "Skitse og markering", "prefs.sketch_selection_note": "Hvordan skitser snapper, og hvordan klik markerer.", + "prefs.sketch_state_icons": "Skitseikoner i farven for deres tilstand", + "prefs.sketch_state_icons_note": "I historikken og modeltræet får en skitses ikon farven for dens tilstand (ikke fuldt bundet, fuldt bundet eller i konflikt), og værktøjstippet angiver tilstanden. Slået fra: Alle skitser har det samme ikon.", + "prefs.sketch_tab_always": "Vis altid fanen Skitse", + "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.sketching": "Skitsering", "prefs.snap_drawing_note": "Mens du tegner eller trækker et skitsepunkt.", "prefs.source": "Kilde", "prefs.speed": "Hastighed", "prefs.stand_up_note": "Når en STEP- eller IGES-fil åbnes med Z opad, tilbydes en kvart omdrejning om X.", + "prefs.stand_up_unknown": "For filer fra ukendte programmer", + "prefs.stand_up_unknown.ask": "Spørg i en dialog", + "prefs.stand_up_unknown.note": "Vis en note", + "prefs.stand_up_unknown.off": "Tilbyd ikke", + "prefs.stand_up_unknown_note": "Hvordan tilbuddet gives, når filens header ikke nævner noget program, der er kendt for at gemme med Y opad. En note vises kun, når filen ser ud til at ligge på ryggen. Spørg i en dialog spørger om hver sådan fil som før.", + "prefs.status_bar": "Statuslinje", + "prefs.status_bar_note": "En tynd linje langs vinduets nederste kant viser, hvad der redigeres, den åbne skitses tilstand i dens farve og designets enheder. Et klik på enhederne ændrer dem. Skitsens tilstand vises så ikke længere i visningens hjørne.", "prefs.status_done": "Færdig", "prefs.status_error": "Fejl", "prefs.status_selected": "Valgt", @@ -1344,50 +2057,608 @@ "prefs.supersampling_brief": "Tegner visningen i større størrelse og nedskalerer den med filtrering: meget skarpt og mere krævende.", "prefs.tab_accent_note": "Accentfarven følger den aktive fane", "prefs.theme_file": "temafil", + "prefs.theme_file_or_paste": "fil eller indsæt", "prefs.theme_follow": "Følg en temafil", "prefs.theme_follow_error": "Ikke anvendt: {error}. Det seneste gyldige tema bevares.", "prefs.theme_follow_launch": "Følges kun denne gang (--theme-file); genindlæses, når et andet værktøj overskriver filen.", "prefs.theme_follow_note": "Genindlæses, når et andet værktøj overskriver filen. Valg af et tema stopper dette.", "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_paste": "Indsæt tema", + "prefs.theme_paste_none": "Udklipsholderen indeholder intet SolidCraft-tema", "prefs.timeline_half_hour": "−30 min", "prefs.timeline_now": "nu", "prefs.timeline_quarter_hour": "−15", + "prefs.toast_place": "Meddelelser", "prefs.toggle_item": "Tilføj elementet, eller fjern det igen", "prefs.toolbar": "Værktøjslinje", + "prefs.toolbar_tabs": "Værktøjslinjefaner", + "prefs.top": "Top", + "prefs.trackpad_zoom_modifier": "Zoom med rulning med to fingre, mens du holder nede", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Hold kun denne tast nede for at zoome ved markøren med lodret rulning. Fra beholder den eksisterende rulleadfærd.", + "prefs.trackpad_zoom_modifier.off": "Fra", "prefs.translucent": "Halvgennemsigtig", "prefs.under_browser": "Under modelbrowseren", "prefs.under_cursor": "Under markøren", "prefs.units_precision": "Enheder og præcision", "prefs.updates": "Opdateringer", "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.", "prefs.viewport": "Visningsområde", + "prefs.viewport_analysis_status": "Vis status for analyser i visningsområdet", + "prefs.viewport_analysis_status_help": "Vis aktive analyser over visningsområdet, åbn deres signaturforklaringer fra hvert element, og brug Esc til at slå den nyeste analyse fra. Slå fra for at gendanne signaturforklaringer i hjørnerne og den tidligere Esc-adfærd.", + "prefs.viewport_background": "Baggrund", + "prefs.viewport_background_colour": "Farve", + "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.wheel": "Musehjul", "prefs.wheel_trackpad": "Musehjul og pegefelt", "prefs.workspace": "Arbejdsområde", + "prefs.zoom_stop_at_model": "Stop zoom ved modellen", + "prefs.zoom_stop_at_model_tip": "3D-musen og enhedsappen zoomer mod overfladen midt i visningen; træk-zoom holder fast i det punkt, hvor trækket begyndte. Fra: Zoom kan passere gennem modellen. Hjul og knibebevægelse bruger Zoom mod markøren separat", "prefs.zoom_toward_cursor": "Zoom mod markøren", "properties.title": "Egenskaber: {name}", "recovery.days_ago": "for {count} dage siden", "recovery.detail": "{file} · {features} features · autogemt {when}", + "recovery.discard_all": "Kassér alle", + "recovery.discard_all_detail": "Dette kan ikke fortrydes.", + "recovery.discard_all_title": "Kassér {count} automatiske lagringer?", + "recovery.discard_all_title_one": "Kassér 1 automatisk lagring?", "recovery.hours_ago": "for {count} t siden", "recovery.just_now": "lige nu", "recovery.min_ago": "for {count} min siden", "recovery.never_saved": "aldrig gemt", + "render.adaptive_absolute": "Absolut nedre grænse for RGB-fejl", + "render.adaptive_coverage": "Mål for dækningsfejl", + "render.adaptive_enable": "Fordel samples med uafhængige piloter", + "render.adaptive_help": "Kun GPU. Uafhængige pilot-samples kasseres, hvorefter 16 eller flere produktions-samples pr. pixel fordeles op til Samples pr. pixel. RGB- og dækningsvarians styrer fordelingen; sjældne lysstier kan blive overset, og pixels, der har nået loftet, kan forblive støjende. Den endelige rapport tæller det faktiske arbejde. Ingen certificeret garanti for støj eller tid.", + "render.adaptive_minimum": "Mindste antal produktions-samples", + "render.adaptive_pending": "GPU-baseret adaptiv fordeling med uafhængige piloter og betingede Fresnel-strata er tilgængelig. Certificeret konvergens, garantier for sjældne hændelser og adaptiv CPU-lystransport er stadig ikke tilgængelige.", + "render.adaptive_pilots": "Pilot-samples pr. pixel", + "render.adaptive_relative": "Mål for relativ RGB-fejl", + "render.add_ground": "Tilføj tilpasset grundplan til opsætningen", + "render.advanced_material_scope": "Ét afgrænset, homogent GPU-medium er tilgængeligt inden for eksplicitte grænser. Densitetsgitre, indlejrede kombinationer af glas og volumen, subsurface-overflader, displacement, spektral lystransport og ubundne avancerede materialekoncepter er stadig ikke tilgængelige.", + "render.aging_add": "Tilføj ældningslag", + "render.aging_airborne": "Støv i luften / deltagende medium", + "render.aging_airborne_help": "Støv i luften er et separat, afgrænset og homogent spredningsvolumen. Det ændrer lystransporten og den rå EXR, ikke stakken af overfladeældning. De eksisterende begrænsninger for volumen, glas og støjreduktion gælder fortsat eksplicit; der tilføjes ingen atmosfære automatisk.", + "render.aging_all_bodies": "Alle egnede uigennemsigtige emner", + "render.aging_chips": "Afskallet maling", + "render.aging_class_any": "Alle uigennemsigtige materialeklasser", + "render.aging_class_dielectric": "Kun ikke-metalliske overflader", + "render.aging_class_metal": "Kun metalliske overflader", + "render.aging_color": "Lagets farve · sRGB", + "render.aging_coverage": "Dækning", + "render.aging_dust": "Aflejret støv", + "render.aging_empty": "Ingen ældningslag: de oprindelige overflader forbliver uændrede.", + "render.aging_fading": "Falmet belægning", + "render.aging_grime": "Snavs og pletter", + "render.aging_help": "Kombinér op til otte optiske lag i rækkefølge. Rust og patina er designede udseender, ikke simuleringer af ældning eller korrosion. Skalaen er i mm i verdenskoordinater. Ingen geometri, masse eller teknisk egenskab ændres; glas og passive kulisser er udeladt.", + "render.aging_metallic": "Lagets metalliske andel", + "render.aging_origin": "Feltets nulpunkt · mm i verdenskoordinater", + "render.aging_patina": "Kobbergrøn patina", + "render.aging_refresh_targets": "Undersøg aktuelle emner", + "render.aging_roughness": "Lagets ruhed", + "render.aging_rust": "Oxideret jern / rust", + "render.aging_scale": "Mønsterskala", + "render.aging_scratches": "Fine ridser", + "render.aging_target": "Mål og koordinater", + "render.aging_target_help": "Et navngivet mål skal stadig passe til sit registrerede indeks og navn. Globale masker er forankret i verden; de følger ikke bevægelige dele. Dette er hverken registrering af hulrum eller ægte kantslitage. Filtrér listen for at nå flere emner.", + "render.aging_target_search": "Filtrér emnenavne", + "render.aging_upward": "Aflejring på opadvendte overflader", + "render.all_controls_help": "Tæt oversigt over alle aktuelle indstillinger. Sektionssiderne giver fokuseret redigering uden at ændre den underliggende anmodning.", + "render.all_shots_label": "Alle optagelser", + "render.appearance_editor.absorption": "Absorption", + "render.appearance_editor.base_colour": "Basisfarve", + "render.appearance_editor.body_reason": "Optiske bindinger gælder for ét helt emne. Vælg Emne for at redigere disse indstillinger.", + "render.appearance_editor.boundary": "Afgrænsning", + "render.appearance_editor.box": "Boks", + "render.appearance_editor.choose_image": "Vælg billede…", + "render.appearance_editor.closed": "Lukket solid", + "render.appearance_editor.coat": "Klarlak", + "render.appearance_editor.cpu_reason": "Kræver CPU ray tracing.", + "render.appearance_editor.glass": "Glas og absorption", + "render.appearance_editor.glass_enable": "Brug fysisk glas på dette emne", + "render.appearance_editor.glass_note": "Lukket glas bruger geometrisk afstand. Det kan ikke dele emne med klarlak eller teksturkort.", + "render.appearance_editor.gpu_reason": "Kræver GPU path tracing.", + "render.appearance_editor.ior": "IOR", + "render.appearance_editor.maps": "Teksturkort", + "render.appearance_editor.normal": "Normal", + "render.appearance_editor.normal_strength": "Normalstyrke", + "render.appearance_editor.origin": "Nulpunkt", + "render.appearance_editor.recipe": "Recept", + "render.appearance_editor.reflection_sampling": "Sampling for alle reflekterende emner", + "render.appearance_editor.reflections": "Refleksioner", + "render.appearance_editor.remove": "Fjern gemt", + "render.appearance_editor.remove_note": "Fjern den gemte optik fra denne optagelse. Geometri og tildelte udseender bevares.", + "render.appearance_editor.remove_title": "Fjern gemt optik?", + "render.appearance_editor.replace": "Erstat dette emnes gemte optik", + "render.appearance_editor.revalidate": "Kontrollér dette emne igen", + "render.appearance_editor.save": "Gem denne optik", + "render.appearance_editor.save_reason": "For at gemme denne optik skal der bruges fysisk glas på et aktuelt GPU-emne. Klarlak og lokale billedkort forbliver midlertidige.", + "render.appearance_editor.saved": "Gemt sammen med denne optagelse", + "render.appearance_editor.saved_state": "Denne optik er gemt sammen med denne optagelse.", + "render.appearance_editor.scatter_reason": "Spredning i det indre kræver en lukket solid afgrænsning.", + "render.appearance_editor.temporary": "Denne optik er midlertidig på denne fane. Gem den for at beholde den sammen med optagelsen.", + "render.appearance_editor.thickness": "Tykkelse", + "render.appearance_editor.thickness_reason": "Tykkelse gælder for tynde plader; lukket glas bruger geometrisk afstand.", + "render.appearance_editor.thin": "Tynd plade", + "render.appearance_editor.u_repeat": "U-gentagelse", + "render.appearance_editor.v_repeat": "V-gentagelse", + "render.appearance_editor.v_up": "Teksturens V peger opad", + "render.appearance_editor.weight": "Vægt", + "render.appearance_physics_separate": "Visuelt udseende tildeler hverken densitet, termiske data eller andre tekniske egenskaber.", + "render.appearances": "Udseender", + "render.assign_help": "Klik for at anvende på markeringen; træk for at se en forhåndsvisning og slip for at anvende, eller klik uden markering for at male.", + "render.availability_native": "Indbygget sammensætning; gennemgå, før den anvendes", + "render.availability_projection": "Understøttet overførsel; se omfang og udeladelser", + "render.availability_reference": "Referencedata; ikke en automatisk tildeling", + "render.backdrop_alpha_conflict": "Vælg enten denne kamerabaggrund eller gennemsigtigt PNG-output; renderingsmotoren ignorerer ikke stiltiende nogen af indstillingerne.", + "render.backdrop_enable": "Brug farveforløb som kamerabaggrund", + "render.backdrop_first": "Startfarve / midterfarve", + "render.backdrop_help": "Denne kamerabaggrund i skærmrummet belyser ikke, kaster ikke skygger, optræder ikke i refleksioner og forestiller ikke terræn. Den er udeladt fra den rå EXR. Geometri, lys og HDR-omgivelserne forbliver uafhængige.", + "render.backdrop_horizontal": "Vandret farveforløb", + "render.backdrop_radial": "Radialt farveforløb", + "render.backdrop_radius": "Radius", + "render.backdrop_second": "Slutfarve / kantfarve", + "render.backdrop_vertical": "Lodret farveforløb", + "render.backdrop_x": "Centrum X", + "render.backdrop_y": "Centrum Y", "render.background": "Scenebaggrund", + "render.background_camera_section": "Kamerabaggrund · PNG-præsentation", + "render.background_completed_frozen": "Det viste resultat er et frosset, færdigt billede. Redigering af en baggrund gentegner ikke resultatet automatisk. Brug Gradér igen til ændringer, der kun gælder kameraet, eller Render til en ny fysisk kulisse.", + "render.background_hdr_separate": "De aktuelle HDR-ressourcer belyser overflader og spejler sig i dem; de vises ikke automatisk som et panorama bag motivet. Ændring af et farveforløb roterer eller omfarver ikke belysningen.", + "render.background_lighting_section": "HDR-belysning · adskilt fra kamerabaggrunden", + "render.background_no_alpha_override": "En kamerabaggrund føjes ikke stiltiende til gennemsigtigt output. Ny farvegradering af den rå master bruger registreret dækning og anvender ikke støjreduktion igen; kilde-EXR'en forbliver uændret.", + "render.background_not_light": "Baggrundsfarven er præsentation, ikke en ekstra lyskilde.", + "render.background_regrade": "Anvend på bevaret rå master", + "render.background_stage_occlusion": "Denne optagelse indeholder fysisk kulissegeometri. Et stort gulv eller en stor væg kan fylde billedet og skjule en kamerabaggrund. Fjern kulissen eller ændr dens størrelse bevidst, eller ændr dens fysiske reflektans. Ny farvegradering kan ikke fjerne registreret geometri.", + "render.background_workflow": "Vælg den baggrund, du ønsker: en kamerabaggrund med farve eller farveforløb, en fysisk kulisse, der modtager lys, eller HDR-belysning. De er uafhængige. Live-forhåndsvisning er en modelleringsvisning; render et nyt billede, eller gradér en bevaret master igen for at se kameraets efterbehandling.", + "render.base_appearance": "Redigerbart emneudseende", + "render.base_apply": "Anvend emneudseende", + "render.base_apply_help": "Én tildeling af udseende, som kan fortrydes, til det gennemgåede, præcise emne. Eksisterende fladespecifikke udseender og fysiske materialer forbliver adskilte.", + "render.base_color": "Basisfarve · sRGB", + "render.base_metallic": "Metallisk andel", + "render.base_opaque_only": "Denne editor bevarer emnets uigennemsigtighed. Brug de eksplicitte glasindstillinger til optisk transmission.", + "render.bloom_enable": "Vis bloom", + "render.bloom_help": "En kreativ glød, der kun påvirker output, ud fra positive lineære højlys og spredt med tre normaliserede box-pas. Radius er i outputpixels. Lægges til i lineært visningslys før sRGB; ikke en linseeffekt, en volumeneffekt eller en forbedring af konvergens. Den rå EXR er uændret.", + "render.bloom_opaque": "Bloom kræver uigennemsigtigt output. Gennemsigtig PNG afvises og gøres ikke stiltiende uigennemsigtig.", + "render.bloom_radius": "Bloom-radius", + "render.bloom_strength": "Bloom-styrke", + "render.bloom_threshold": "Tærskel for lineære højlys", + "render.browse_capabilities": "Undersøg det fulde katalog over muligheder", + "render.browse_environments": "Gennemse originale HDR-omgivelser", + "render.browse_looks": "Gennemse…", + "render.browse_native_scenes": "Gennemse indbyggede studioer", + "render.browse_physical": "Undersøg fysiske referencer", + "render.browse_quality": "Undersøg kvalitetsspecifikationer", + "render.browse_rigs": "Gennemse designede lysrig", + "render.browse_scene_specs": "Gennemse avancerede scenespecifikationer", + "render.browse_surfaces": "Gennemse designede overflader", + "render.browser_library": "Bibliotek", + "render.browser_model": "Model", + "render.camera_distance": "Afstand fra øje til mål", + "render.camera_exact_help": "Mm i verdenskoordinater; vinkler vises i grader og gemmes som radianer. Navigationen bruger samme kamera som visningsområdet.", + "render.camera_fov": "Lodret synsfelt", + "render.camera_pitch": "Stampning", + "render.camera_roll": "Rulning", + "render.camera_set": "Angiv renderingskamera", + "render.camera_target": "Målposition · mm", + "render.camera_yaw": "Krøjning", + "render.category_metal": "Metal", + "render.category_paint": "Maling", + "render.category_plastic": "Plast", + "render.category_wood": "Træ", + "render.check_opaque": "Gennemsigtighed: kun uigennemsigtige overflader; baggrundsalfa understøttes.", + "render.check_sorted_alpha": "Gennemsigtighed: tilnærmelse med sorteret alfa.", + "render.check_transmission": "Gennemsigtighed: fysisk transmission.", + "render.children.absolute_floor": "Absolut grænse", + "render.children.adaptive": "Adaptiv", + "render.children.add_look_reason": "Tilføj et look for at redigere, om det er aktiveret, og dets styrke.", + "render.children.all_shots": "Alle optagelser", + "render.children.bloom": "Bloom", + "render.children.check": "Kontrollér", + "render.children.check_now": "Kontrollér nu", + "render.children.chromatic": "Kromatisk", + "render.children.compact": "Kompakt", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopiér", + "render.children.copy_job": "Kopiér job-JSON", + "render.children.copy_request": "Kopiér anmodnings-JSON", + "render.children.coverage_error": "Dækningsfejl", + "render.children.cpu": "CPU ray tracing", + "render.children.denoise": "Reducér støj i det færdige billede", + "render.children.depth": "Path-dybde", + "render.children.distortion": "Forvrængning", + "render.children.edge_samples": "Kant-samples", + "render.children.effects_banner": "Disse effekter kræver GPU path tracing. Åbning af dette ændrer aldrig metoden.", + "render.children.enable_adaptive": "Aktivér uafhængige piloter for at redigere de adaptive grænser.", + "render.children.enable_bloom": "Aktivér bloom for at redigere dens styrke, tærskel og radius.", + "render.children.finishing": "Efterbehandling", + "render.children.flare": "Flare", + "render.children.fresnel": "Fresnel-strata", + "render.children.ghosts": "Ghost-ringe", + "render.children.glass": "Glas-lystransport", + "render.children.go_to": "Gå til {editor}", + "render.children.grain": "Filmkorn", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Behold lineær rå master", + "render.children.last_result": "Seneste: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Linseeffekter", + "render.children.look_summary": "{tone} · {count} looks", + "render.children.looks": "Kreative looks", + "render.children.memory": "Hukommelse", + "render.children.method": "Metode", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Kræver CPU ray tracing. Åbning af dette ændrer aldrig metoden.", + "render.children.nested": "Certificerede indlejrede dielektrika", + "render.children.not_checked": "Ikke kontrolleret", + "render.children.path": "Path tracing", + "render.children.pilots": "Pilot-samples", + "render.children.preview": "Forhåndsvisningsdata", + "render.children.progressive_reason": "Vælg Progressiv for at redigere antal samples og seed.", + "render.children.radius": "Radius", + "render.children.reflect_depth": "Refleksionsdybde", + "render.children.reflect_samples": "Refleksions-samples", + "render.children.regrade": "Gradér igen", + "render.children.relative_error": "Relativ fejl", + "render.children.render": "Render", + "render.children.render_shot": "Render optagelse", + "render.children.renderer": "Renderingsmotor", + "render.children.renderers": "Renderingsmotorer", + "render.children.request": "Denne anmodning", + "render.children.reset_finishing": "Nulstil efterbehandling", + "render.children.review_quality": "Gennemgå en kvalitetsspecifikation…", + "render.children.rgb_gain": "RGB-forstærkning", + "render.children.samples": "Samples", + "render.children.sampling": "Sampling", + "render.children.saturation": "Farvemætning", + "render.children.scene_summary": "{bodies} emner · {triangles} trekanter", + "render.children.seed": "Seed", + "render.children.sharpening": "Skærpning", + "render.children.single_image": "Enkeltbillede", + "render.children.soft_focus": "Blødt fokus", + "render.children.streak": "Striber", + "render.children.strength": "Styrke", + "render.children.studio": "Studio", + "render.children.threshold": "Tærskel", + "render.children.tone": "Tone", + "render.children.under_hood": "Under motorhjelmen", + "render.children.vignette": "Vignettering", + "render.children.workstation": "Arbejdsstation", + "render.choose_look": "Vælg først et udseende under Udseender", + "render.chromatic_offset": "Kromatisk kantforskydning", + "render.clear_stage": "Fjern gemt kulisse", + "render.copy_request": "Kopiér JSON", + "render.cpu_aa_help": "Rumlige samples: flere samples forbedrer kanter og øger renderingstiden.", + "render.cpu_area_add": "Tilføj rektangulært CPU-lys", + "render.cpu_area_editor": "Rektangulære CPU-lys", + "render.cpu_area_editor_help": "Fulde sidevektorer i millimeter; krydsproduktet U × V peger mod den side, der udsender lys. Det adskiller sig fra GPU'ens halvkantvektorer.", + "render.cpu_area_full_u": "Fuld U-side · mm", + "render.cpu_area_full_v": "Fuld V-side · mm", + "render.cpu_area_samples": "Samples for arealys", + "render.cpu_binding_confirm": "Bekræft dette emne", + "render.cpu_binding_stale": "Scenen er ændret. Bekræft dette refleksionsmål igen, før der renderes.", + "render.cpu_body_choose": "Vælg et emne", + "render.cpu_body_filter": "Filtrér emner efter navn", + "render.cpu_body_filter_more": "Forfin filteret for at se flere emner.", + "render.cpu_body_missing": "Et refleksionsmål mangler, er skjult, er flertydigt eller har et navn på mere end 128 bytes. Vælg det igen.", + "render.cpu_budget": "CPU-ressourceprofil", + "render.cpu_budget_bodies": "Grænse for emner", + "render.cpu_budget_compact": "Kompakt (ældre grænser)", + "render.cpu_budget_details": "Ressourcegrænser", + "render.cpu_budget_help": "Vælg eksplicit større grænser. Opløsning og samples reduceres aldrig automatisk.", + "render.cpu_budget_memory": "Grænse for jobbets hukommelse", + "render.cpu_budget_not_guarantee": "Det er adgangsgrænser, ikke en RAM-reservation eller en hastighedsgaranti. Geometri, billedbuffer og bevarede forhåndsvisninger deler hukommelsesbudgettet; rendering kan stadig annulleres.", + "render.cpu_budget_resize": "Kladden overskrider denne CPU-profil eller dens budget for kamera-samples. Acceptér mindre dimensioner eksplicit, eller vælg en større profil.", + "render.cpu_budget_studio": "Studio (store billeder)", + "render.cpu_budget_triangles": "Grænse for trekanter", + "render.cpu_budget_workstation": "Workstation (tunge scener)", + "render.cpu_captured_hdr": "HDR registreret til dette billede", + "render.cpu_check_capture_help": "Kontrollér og Start verificerer samme filhash eller bruger den indlejrede HDR. Hvert billede beholder sin registrerede kilde.", + "render.cpu_check_changed": "Indstillingerne eller modellen blev ændret efter denne kontrol. Kontrollér igen, før der renderes.", + "render.cpu_constant_help": "Konstant strålingstæthed er synlig for reflekterede eller transmitterede stråler. Den giver ikke direkte diffus belysning.", + "render.cpu_custom_directions": "Erstat forudindstilling med brugerdefinerede retningslys", + "render.cpu_direction": "Retning mod lyset (X, Y, Z)", + "render.cpu_directional": "Retningslys", + "render.cpu_directional_add": "Tilføj retningslys", + "render.cpu_environment": "CPU-omgivelser", + "render.cpu_environment_background": "Billedets baggrund er adskilt fra omgivelsesbelysningen.", + "render.cpu_environment_bounds": "CPU-intensiteten skal være 0–64, HDR-rotationen et gyldigt tal inden for ±1.000.000 radianer og HDR-samples 1, 4, 16 eller 64.", + "render.cpu_environment_constant": "Konstante omgivelser for sekundære stråler", + "render.cpu_environment_none": "Ingen CPU-omgivelser", + "render.cpu_environment_samples": "HDR-belysnings-samples", + "render.cpu_exposure_help": "CPU-eksponeringen skal ligge mellem −16 og 16 EV. En GPU-eksponering uden for dette område bevares, indtil du ændrer den.", + "render.cpu_fixed": "Enkeltbillede", + "render.cpu_hdr_help": "Radiance .hdr, 2:1-panorama, op til 2048 × 1024 og 16 MiB. CPU kræver eksplicitte sRGB/D65-primærfarver i filen; uspecificerede primærfarver og EXR afvises.", + "render.cpu_hdr_reflection_help": "Ru HDR-refleksioner kræver fuldt metalliske overflader på emner og flader. Kontrollen rapporterer kombinationer, der ikke understøttes.", + "render.cpu_key_rim": "Hovedlys og kantlys", + "render.cpu_last_check": "Seneste CPU-kontrol", + "render.cpu_light_help": "Op til otte retningslys. En tom brugerdefineret liste slår dem fra; valg af en forudindstilling gendanner dens lys. Farver er lineær RGB, og intensiteten er relativ, ikke i watt.", + "render.cpu_light_intensity": "Relativ intensitet (0–64)", + "render.cpu_light_minimum": "Mindsteafstand for dæmpning", + "render.cpu_light_position": "Position i verdenskoordinater (X, Y, Z)", + "render.cpu_light_reference": "Referenceafstand for intensitet", + "render.cpu_light_remove": "Fjern lys", + "render.cpu_lighting": "Forudindstilling for belysning", + "render.cpu_lights": "CPU-lys", + "render.cpu_linear_color": "Lineær farve (R, G, B)", + "render.cpu_neutral": "Neutral", + "render.cpu_opaque_required": "CPU ray tracing kræver en uigennemsigtig baggrund. Fjern markeringen ved Gennemsigtig baggrund for at fortsætte.", + "render.cpu_options_pending": "Dette CPU-panel kan endnu ikke redigere emission, overfladeeffekter, glas eller anisotropi. Fjern disse API-indstillinger, før du bruger det.", + "render.cpu_point": "Punktlys", + "render.cpu_point_add": "Tilføj punktlys", + "render.cpu_point_help": "Op til otte punktlys, lagt oven i den valgte retningsbelysning. Afstande er i mm; minimumsafstanden må ikke overstige referenceafstanden.", + "render.cpu_primary_samples": "Kant-samples pr. pixel", + "render.cpu_progressive": "CPU progressiv", + "render.cpu_progressive_help": "Akkumulér den eksisterende CPU-integrator i gennemløb med ét primært sample. Valgte refleksioner kræver ét sample; arealys, dielektrika og anisotropi kræver Enkeltbillede.", + "render.cpu_progressive_limits": "Højst 1.048.576 pixels og 16.777.216 pixel × sample-evalueringer. Ændring af samples ændrer aldrig billedets størrelse automatisk.", + "render.cpu_progressive_seed": "CPU-sampling-seed", + "render.cpu_progressive_spp": "samples pr. pixel", + "render.cpu_reflection_add": "Tilføj ru refleksion", + "render.cpu_reflection_depth": "Refleksionsdybde (1–8)", + "render.cpu_reflection_empty": "Ingen emner valgt: scenerefleksioner er slået fra.", + "render.cpu_reflection_help": "Vælg emner, og sæt Fra, Spejl eller Ru. Ru bruger den tildelte overfladeruhed. Flere samples og bounces øger tiden; det er begrænset spekulær lystransport, ikke global belysning.", + "render.cpu_reflection_limit": "Op til 64 reflekterede emner understøttes.", + "render.cpu_reflection_mirror": "Spejl", + "render.cpu_reflection_off": "Fra", + "render.cpu_reflection_rough": "Ru", + "render.cpu_reflection_samples": "Refleksions-samples (1–64)", + "render.cpu_reflections": "CPU-refleksioner", + "render.cpu_runtime_help": "Numeriske indstillinger for lys og omgivelser gemmes sammen med opsætningen. Emnerefleksioner og HDR-filer forbliver midlertidige. Eksisterende værdier for arealys og spotkegler bevares.", + "render.cpu_sampling_mode": "CPU-samplingtilstand", + "render.cpu_shadows": "Kast skygger", + "render.cpu_summary": "CPU ray tracing · refleksioner · HDR · retningslys og punktlys", + "render.cpu_warm_cool": "Varm og kold", + "render.diag_bodies": "Synlige emner", + "render.diag_job_details": "Registrerede jobdetaljer", + "render.diag_last_check": "Seneste forhåndskontrol", + "render.diag_memory": "Logisk estimat for jobadgang", + "render.diag_not_implemented": "Eksplicitte funktionsgrænser", + "render.diag_preflight_details": "Detaljer fra forhåndskontrol", + "render.diag_request": "Den præcise aktuelle anmodning", + "render.diag_triangles": "Trekanter", + "render.discard_continue": "Kassér kladde og fortsæt", + "render.draft_confirm_help": "Den aktuelle renderingskladde har ændringer, der ikke er anvendt. Gem dem, før du skifter, kassér dem eksplicit, eller fortsæt med at redigere.", + "render.draft_confirm_title": "Bevar redigerede renderingsindstillinger", + "render.effects_gpu_only": "Disse effekter kræver GPU path tracing. Ændring af siden skifter aldrig renderingsmotor automatisk.", + "render.effects_help": "Kombinér ordnede farve-looks og linseeffekter i billedplanet. Rendering og ny farvegradering af rå master bruger de samme gemte indstillinger. Rå EXR forbliver uændret. Det er originale kunstneriske værktøjer, ikke kalibrerede emuleringer af bestemte linser eller mærkefilm.", + "render.effects_reset_lens": "Nulstil linseeffekter", + "render.effects_seed": "Mønster-seed", + "render.emitted_axis": "Emissionsakse", + "render.env_advanced": "Avanceret", + "render.env_backdrop": "Kamerabaggrund", + "render.env_backdrop_pending": "Synlighed af omgivelserne og baggrundsslør understøttes endnu ikke af renderingsmotoren.", + "render.env_blur": "Slør", + "render.env_change": "Skift", + "render.env_choose_source": "Vælg omgivelser under Studioer og omgivelser, før belysningen redigeres.", + "render.env_clear": "Ryd", + "render.env_colour": "Farve", + "render.env_environments": "Omgivelser", + "render.env_ground": "Grundplan", + "render.env_ground_pending": "Den fysiske grundplan modtager skygger som en afgrænset diffus overflade. Kun skyggefangst og refleksioner på grundplanen understøttes endnu ikke.", + "render.env_intensity": "Intensitet", + "render.env_invalid_edit": "Ugyldig redigering af omgivelser.", + "render.env_physical_ground": "Fysisk grundplan", + "render.env_preview_help": "Illustrativ eksempelkugle; ikke en forhåndsvisning af de valgte omgivelser.", + "render.env_reflection": "Refleksion", + "render.env_ring_help": "Træk i ringen for at rotere belysningen. Hold Shift nede for at snappe til 15°.", + "render.env_rotate": "Rotér", + "render.env_set": "Angiv omgivelser for rendering", + "render.env_shadow_catcher": "Skyggefanger", + "render.env_show_environment": "Vis omgivelser", + "render.env_source": "Kilde", + "render.env_stage_details": "Ændringer af grundplanen kræver ray tracing og ét enkelt grundpanel. Brug Scene til at redigere andre kulissepaneler.", + "render.env_studios": "Studioer", + "render.environment.albedo": "Albedo", + "render.environment.atmosphere": "Atmosfære", + "render.environment.backdrop_note": "En kamerabaggrund ses kun af kameraet. Den giver aldrig lys, kaster ikke skygger og reflekterer ikke, og den afvises ved gennemsigtigt output.", + "render.environment.blur_pending": "Slør er ikke tilgængeligt: renderingsmotoren har ingen indstilling for baggrundsslør.", + "render.environment.builtin": "Indbygget HDR", + "render.environment.check": "Kontrollér fil", + "render.environment.checked": "HDR-filen er kontrolleret. Ved gemning indlejres disse bytes.", + "render.environment.choose": "Vælg HDR…", + "render.environment.constant": "Konstant", + "render.environment.cyclorama": "Cyklorama", + "render.environment.embedded": "Gemt i dette design", + "render.environment.end": "Slut", + "render.environment.extinction": "Ekstinktion", + "render.environment.file": "Fil", + "render.environment.fit": "Tilpas og gem grundplan", + "render.environment.floor_wall": "Gulv og væg", + "render.environment.gap": "Frigangsafstand", + "render.environment.gradient": "Farveforløb", + "render.environment.gradient_gpu": "Kamerabaggrund med farveforløb kræver GPU path tracing.", + "render.environment.ground": "Grundplan og kulisse", + "render.environment.hdr": "HDR", + "render.environment.hdr_file": "HDR-fil", + "render.environment.horizon": "Horisont", + "render.environment.horizontal": "Vandret", + "render.environment.keep": "Behold", + "render.environment.lighting_samples": "Samples til belysning", + "render.environment.margin": "Udstrækningsmargen", + "render.environment.max": "Maks. grænser", + "render.environment.medium": "Deltagende medium", + "render.environment.min": "Min. grænser", + "render.environment.mode": "Tilstand", + "render.environment.needs_gpu": "Kræver GPU path tracing.", + "render.environment.none": "Ingen", + "render.environment.orientation": "Orientering og styrke", + "render.environment.phase": "Fase", + "render.environment.primaries": "Behandl uspecificerede primærfarver som lineær sRGB", + "render.environment.primaries_gpu": "Fortolkning af uspecificerede primærfarver kræver GPU path tracing.", + "render.environment.radial": "Radial", + "render.environment.radial_only": "Centrum og radius gælder for radial tilstand.", + "render.environment.reflectance": "Reflektans", + "render.environment.remove": "Fjern kulisse", + "render.environment.remove_confirm": "Fjern den gemte kulisse?", + "render.environment.remove_note": "Fjerner de {count} passive paneler fra denne optagelse. Kamerabaggrunden forbliver.", + "render.environment.replace": "Bekræft udskiftning af den eksisterende kulisse", + "render.environment.rotation_hdr": "Rotation kræver en HDR-kilde til CPU ray tracing.", + "render.environment.samples_cpu": "Samples til belysning styres af budgettet for GPU path tracing; denne indstilling kræver CPU ray tracing.", + "render.environment.saved_panels": "Gemte paneler {count}", + "render.environment.sky_ground": "Jord", + "render.environment.sky_note": "Originalt himmelfarveforløb til belysning; ikke en kalibreret atmosfære.", + "render.environment.source_raytrace": "Omgivelseskilder kræver CPU- eller GPU-ray tracing.", + "render.environment.stage_raytrace": "Fysiske kulisser kræver CPU- eller GPU-ray tracing.", + "render.environment.start": "Start", + "render.environment.vertical": "Lodret", + "render.environment.world": "Verden", + "render.environment.world_pending": "Verdensbaggrund er ikke tilgængelig: omgivelsernes synlighed for kameraet har endnu ingen gemt indstilling.", + "render.environment.zenith": "Zenit", + "render.environment_tiles_require_raytrace": "Omgivelser kræver CPU- eller GPU-ray tracing. Vælg det i outputeditoren.", + "render.error_area_samples": "area_samples skal være 1, 4, 16 eller 64", + "render.error_binding_allocation": "kunne ikke reservere materialebinding", + "render.error_body_bound": "produktforhåndsvisningen overstiger grænsen for registrerede emner", + "render.error_checkpoint_dimensions": "checkpointets dimensioner afviger fra den registrerede anmodning", + "render.error_decode_budget": "overløb i budgettet for billedafkodning", + "render.error_denoise_setting": "ugyldig indbygget indstilling for støjreduktion", + "render.error_display_dimensions": "ugyldige dimensioner for billedvisning", + "render.error_display_fit": "billedvisningen kan ikke tilpasses denne enhed", + "render.error_display_height": "overløb i visningshøjde", + "render.error_display_width": "overløb i visningsbredde", + "render.error_duplicate_face": "duplikeret udseende for produktflade", + "render.error_export_document": "Eksportdokumentet for rendering blev ændret, mens destinationen blev valgt", + "render.error_face_cap": "produktforhåndsvisningen overstiger grænsen for tilladte fladeudseender", + "render.error_finish_gesture": "afslut modelleringsbevægelsen før forhåndsvisning", + "render.error_finish_operation": "afslut den aktuelle modelleringshandling, før Rendering åbnes", + "render.error_fixed_preview": "fast studioforhåndsvisning kræver eksponering 0, omgivelsesrotation 0 og intensitet 1", + "render.error_geometry_size": "overløb i produktgeometriens størrelse", + "render.error_glass_recipe": "ukendt glasopskrift", + "render.error_gpu_preview": "GPU-produktforhåndsvisning er ikke tilgængelig på denne vært. Gemte opsætninger er stadig tilgængelige.", + "render.error_gpu_samples": "antallet af GPU-samples understøttes ikke", + "render.error_image_dimensions": "renderingsbilledets dimensioner svarer ikke til dets job", + "render.error_image_profile": "renderingsbilledet overstiger sin registrerede ressourceprofil", + "render.error_interactive_command": "interaktiv Render-kommando uden binding", + "render.error_job_action": "ikke en interaktiv handling for billedjob", + "render.error_job_identity": "ugyldig identitet for renderingsjob: {error}", + "render.error_library_reply": "ugyldigt svar fra renderingsbiblioteket", + "render.error_material_binding": "manglende materialebinding", + "render.error_no_completed_image": "renderingen har intet færdigt billede", + "render.error_no_geometry": "ingen produktgeometri", + "render.error_no_scene": "ingen produktscene", + "render.error_opacity": "ugyldig opacitet for produktudseende", + "render.error_pbr_opacity": "uigennemsigtig PBR kræver opacitet 1", + "render.error_physical_glass": "den valgte opskrift er ikke fysisk glas", + "render.error_preview_device": "produktforhåndsvisning kræver en installeret enhed med dybdebuffer", + "render.error_product_triangle": "ugyldig produkttrekant", + "render.error_provisional_identity": "ugyldig foreløbig renderingsidentitet eller ugyldigt antal samples", + "render.error_quality_bounds": "kvalitetsoverførslen overstiger de indbyggede grænser", + "render.error_quality_method": "kvalitetsoverførslen kræver den samme eksplicitte GPU-metode", + "render.error_quality_projection": "ugyldig indbygget kvalitetsoverførsel", + "render.error_regrade_identity": "ugyldig identitet for ny farvegradering: {error}", + "render.error_scene_bodies": "manglende emner i renderingsscenen", + "render.error_scene_body_limit": "renderingsscenen overstiger sin grænse for emner", + "render.error_scene_identity": "ugyldig identitet for renderingsscene", + "render.error_setup_panicked": "opsætningen af GPU path tracer stoppede uventet", + "render.error_setup_stopped": "opsætningstråden stoppede uden at returnere en vært", + "render.error_setup_worker": "kunne ikke starte opsætningstråd: {error}", + "render.error_source_preview": "kildeforhåndsvisningen overstiger sin bytegrænse", + "render.error_surface_normals": "PBR kræver overfladenormaler, der ikke er nul", + "render.error_surface_reply": "ugyldigt svar om designet overflade", + "render.error_triangle_bound": "produktforhåndsvisningen overstiger kildegrænsen på otte millioner trekanter", + "render.error_triangle_count": "overløb i antallet af produkttrekanter", + "render.error_upload_slot": "uploadplads til produktforhåndsvisning er ikke tilgængelig", + "render.error_vertex_allocation": "allokering af vertices til produktforhåndsvisning mislykkedes", + "render.error_vertex_envelope": "produktforhåndsvisningen overstiger sin grænse på {limit} MiB vertex-data; geometrien blev ikke forenklet", + "render.error_vertex_packing": "pakningen af produktforhåndsvisningen afviger fra de godkendte vertex-data", + "render.error_visible_solid": "Opret et synligt massivt emne i Design for at forhåndsvise det her.", + "render.exit_focus": "Afslut fokus", + "render.export_display": "Eksport: PNG til visning", + "render.export_image_label": "Eksportér billede", + "render.export_linear": "Eksport: rå lineær EXR", + "render.exr_pending": "GPU kan bevare rå lineær EXR. Komprimering, flerlagsbilleder og supplerende renderingspas er endnu ikke tilgængelige.", + "render.film_grain": "Fotografisk korn", "render.fit": "Tilpas til produkt", + "render.fixed_camera": "Kamera", + "render.fixed_world": "Verden", + "render.flare_ghosts": "Ghost-ringe pr. kilde", + "render.flare_halo": "Flare-halo", + "render.flare_streak": "Streak i anamorfisk stil", + "render.flare_strength": "Styrke af flare i højlys", + "render.flare_threshold": "Scenelineær tærskel for flare", + "render.fold_browser": "Fold modeltræet sammen", + "render.frame_aspect": "Sideforhold", + "render.frame_centre_mark": "Midtermarkering", + "render.frame_copy_limit": "Intet ledigt navn til en kopi af optagelsen", + "render.frame_copy_name": "{name} kopi {number}", + "render.frame_custom": "Brugerdefineret", + "render.frame_distance_unit": " × størrelse", + "render.frame_duplicate": "Duplikér", + "render.frame_exposure": "Eksponering", + "render.frame_fov": "Synsfelt", + "render.frame_guides": "Hjælpelinjer", + "render.frame_invalid_aspect": "Ugyldig forudindstilling for sideforhold", + "render.frame_lens": "Synsfelt {fov}° · ≈ {mm} mm", + "render.frame_name": "Navn", + "render.frame_output_size": "Outputstørrelse", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Portræt", + "render.frame_presets": "Forudindstillinger for billedudsnit", + "render.frame_presets_pending": "Kommer med optagelseseditoren", + "render.frame_projection": "Projektion", + "render.frame_render_shot": "Render denne optagelse", + "render.frame_rule_thirds": "Tredjedelsreglen", + "render.frame_safe_area": "Sikkert område", + "render.frame_shot_title": "Optagelse · {name}", + "render.frame_square": "1:1 · Kvadrat", + "render.frame_tall": "9:16 · Lodret", + "render.frame_thirds": "Tredjedele", + "render.frame_wide": "16:9 · Bredformat", + "render.gpu_depth": "Maksimal path-dybde", + "render.gpu_options_help": "Samples, dybde, seed og eksponering gemmes med denne opsætning. Kun den aktive billedmetode gemmes.", + "render.gpu_path_failed": "GPU path tracer er ikke tilgængelig", + "render.gpu_path_first_setup": "Den første opsætning på denne GPU kan tage flere minutter; SolidCraft kan stadig bruges imens. Senere starter genbruger de kompilerede shaders, hvor driveren beholder dem.", + "render.gpu_path_preparing": "GPU path tracer: kompilerer shaders…", + "render.gpu_seed": "Tilfældig seed", + "render.gpu_unavailable": "GPU path tracing er ikke klar på denne enhed.", + "render.ground_apply": "Tilpas og gem grundplan", + "render.ground_gap": "Eksplicit frigangsafstand", + "render.ground_linear_rgb": "Diffus reflektans · lineær RGB", + "render.ground_margin": "Margen for grundplanens udstrækning", + "render.hdr_details": "Detaljer om HDR-kilden", + "render.hdr_embedded": "HDR gemt i dette design", + "render.hdr_embedded_help": "Rendering bruger de bytes, der er inkluderet i designet; den oprindelige fil er ikke længere nødvendig.", + "render.hdr_file_captured": "HDR-filen er kontrolleret. Ved gemning indlejres disse bytes; hvis filen ændres, skal den kontrolleres igen eksplicit.", + "render.hdr_inspect": "Kontrollér HDR-fil", + "render.hdr_inspect_required": "Vælg og kontrollér en HDR-fil, før der gemmes eller renderes.", + "render.hdr_primaries_declared": "Kilden angiver sine primærfarver.", + "render.hdr_primaries_srgb": "Primærfarver: sRGB/D65", + "render.hdr_primaries_undeclared": "Kilden angiver ikke primærfarver; gennemgå renderingsmotorens farvekrav.", + "render.hdr_resource_missing": "Dette design mangler den gemte HDR-ressource.", + "render.heading_batch": "BATCH", + "render.heading_frame": "BILLEDUDSNIT", + "render.heading_lens": "LINSE", + "render.heading_results": "RESULTATER", + "render.heading_shots": "OPTAGELSER", + "render.hide_shots_label": "Skjul optagelsesstriben", + "render.hover_look": "{object} · {look} · klik for at vælge", + "render.image_advanced": "Avancerede GPU-indstillinger", "render.image_cancelled": "Rendering annulleret", "render.image_cancelling": "Annullerer", "render.image_completed": "Billedet er klar til eksport", "render.image_encoding": "Gemmer som PNG", "render.image_export": "Eksportér PNG", + "render.image_exposure": "Eksponering", "render.image_failed": "Rendering mislykkedes", "render.image_height": "Højde", "render.image_help": "CPU-forhåndsvisning · PNG · forenklet gengivelse af gennemsigtighed", + "render.image_host_missing": "Ingen billedvært er installeret.", + "render.image_method": "Billedmetode", "render.image_overwrite": "Erstat eksisterende fil", "render.image_preparing": "Klargør billede", + "render.image_progressive": "Progressiv rendering", "render.image_queued": "Venter på rendering", "render.image_reduce": "Prøv et mindre billede eller en enklere scene.", "render.image_running": "Renderer billede", + "render.image_samples": "Samples pr. pixel", + "render.image_scene_stale": "Afslut modelleringsbevægelsen, eller opdatér scenen, før et billede kontrolleres eller startes.", "render.image_stale": "Dette billede bruger scenen, som den var før efterfølgende ændringer.", "render.image_start": "Start rendering", "render.image_supported": "Renderingsanmodningen understøttes; kø og hukommelsespulje kontrolleres igen ved start.", @@ -1397,17 +2668,513 @@ "render.image_unknown": "Renderingsstatus utilgængelig", "render.image_use_largest": "Brug største størrelse med dette sideforhold:", "render.image_width": "Bredde", + "render.include_rig_cards": "Medtag passive plader og flag", + "render.inner_half_angle": "Indre halvvinkel", + "render.inspect_scene": "Opdatér oversigt over den registrerede scene", + "render.inspector": "Inspektør", + "render.inspector_advanced": "Avanceret", + "render.inspector_all_users": "Alle {count} brugere", + "render.inspector_applies_to": "Gælder for", + "render.inspector_both_users": "Begge brugere", + "render.inspector_chain": "Hvor dette udseende kommer fra", + "render.inspector_colour": "Farve", + "render.inspector_edit": "Redigér", + "render.inspector_finish": "Finish", + "render.inspector_from_material": "Fra materiale", + "render.inspector_gloss": "Blank", + "render.inspector_material": "Materiale", + "render.inspector_matte": "Mat", + "render.inspector_metal": "Metal", + "render.inspector_opacity": "Opacitet", + "render.inspector_opaque_help": "Felter for finish og optik bruger uigennemsigtig PBR (opacitet 1). Lavere opacitet bruger skalar skygning.", + "render.inspector_pick_target": "Vælg et mål af denne type for at se dets udseende.", + "render.inspector_remove": "Fjern tilsidesættelse", + "render.inspector_replace": "Erstat…", + "render.inspector_reset": "Nulstil", + "render.inspector_satin": "Satin", + "render.inspector_save_library": "Gem i bibliotek…", + "render.inspector_set_all": "Blandede udseender. Redigering angiver udseendet på alle valgte mål.", + "render.inspector_soon": "Snart", + "render.inspector_target_limit": "Vælg højst 10000 mål for at se udseender.", + "render.inspector_textures": "Teksturer", + "render.inspector_this_body": "Dette emne", + "render.inspector_this_component": "Denne komponent", + "render.inspector_this_face": "Denne flade", + "render.inspector_this_target": "Dette mål", + "render.inspector_used_by": "Bruges af {count}", + "render.keep_editing": "Fortsæt redigering", + "render.layer_down": "Flyt ned", + "render.layer_enabled": "Lag aktiveret", + "render.layer_off_suffix": " (fra)", + "render.layer_remove": "Fjern lag", + "render.layer_strength": "Lagstyrke", + "render.layer_up": "Flyt op", + "render.lens_effects_help": "Flare bruger op til fire adskilte lyse kilder i det faktiske lineære billede. Ghosts, halo, streaks, kromatiske forskydninger og radial forvrængning er kunstneriske tilnærmelser i billedplanet. Rumlige effekter kræver uigennemsigtigt output; samples uden for billedet forlænger billedets kant. Korn er deterministisk. Hverken fysik, EXR eller geometri ændres.", + "render.lens_effects_title": "Linseinspirerede og fotografiske", + "render.library_all": "Katalog", + "render.library_all_states": "Alle tilgængelighedsstatusser", + "render.library_appearance_available": "Tilgængeligt udseende", + "render.library_applicable": "Anvendelige ressourcer", + "render.library_applied": "Ressourceoverførsel anvendt. Kontrollér hele renderingsanmodningen, før den startes.", + "render.library_apply_appearance": "Anvend udseende", + "render.library_apply_hdr": "Gem med disse HDR-omgivelser", + "render.library_apply_hint": "Klik for at anvende · træk ind på modellen", + "render.library_apply_maps": "Brug designede GPU-teksturkort til overflader", + "render.library_apply_studio": "Gem designet studiooverførsel", + "render.library_apply_uniform": "Anvend ensartet uigennemsigtigt udseende", + "render.library_authored_help": "Originale designede ressourcer er illustrative, ikke målte tekniske data. Hver handling anvender kun sin angivne overførsel.", + "render.library_blocked": "Blokeret / kræver kalibrering", + "render.library_capabilities": "Funktionsspecifikationer", + "render.library_center": "Rigens centrum", + "render.library_clear_search": "Ryd", + "render.library_details": "Specifikation og herkomst", + "render.library_emitters_only_ack": "Brug kun lyskilder; udelad de dokumenterede passive plader", + "render.library_empty": "Ingen matchende ressourcer. Prøv en anden søgning eller et andet tilgængelighedsfilter.", + "render.library_environment_concepts": "Omgivelseskoncepter", + "render.library_extent": "Motivets udstrækning", + "render.library_favorite": "Tilføj favorit", + "render.library_favorites": "Favoritter", + "render.library_filters": "Katalog og tilgængelighed", + "render.library_grid": "Gitter", + "render.library_hdr": "Indbyggede HDR-omgivelser", + "render.library_hdr_help": "Indlejrer den originale HDR i den gemte opsætning. Det designede nulpunkt for længdegrad konverteres eksplicit; ingen ekstern fil er nødvendig efter gemning.", + "render.library_in_design": "I Design", + "render.library_inspect_only": "Vælg for at se nærmere; intet anvendes automatisk. Favoritter og seneste historik hører til denne åbne designsession.", + "render.library_ior": "IOR", + "render.library_light_concepts": "Belysningskoncepter", + "render.library_list": "Liste", + "render.library_maps_help": "Indbyggede teksturkort kan gemmes med fastlåste kilder og lokal mapping af forekomster. Midlertidige optiske tilsidesættelser forbliver en separat tilstand.", + "render.library_material_concepts": "Materialekoncepter", + "render.library_metallic": "Metallisk", + "render.library_native_scenes": "Indbyggede studioer", + "render.library_next": "Næste side", + "render.library_omitted": "Ikke inkluderet", + "render.library_opacity": "Opacitet", + "render.library_other": "Andre udseender", + "render.library_physical_profiles": "Fysiske referenceprofiler", + "render.library_previous": "Forrige side", + "render.library_quality_concepts": "Kvalitetsspecifikationer", + "render.library_recent": "Seneste", + "render.library_replace_environment": "Erstat de eksisterende omgivelser eksplicit", + "render.library_replace_studio": "Erstat gemt belysning, omgivelser og kulisse", + "render.library_results": "Matchende ressourcer", + "render.library_rig_help": "Tilpas én gang ved et eksplicit centrum og en fysisk udstrækning. Komplette lysrig omfatter passive plader; kalibreringsblokeringer medfører stadig afvisning. Konservativ frigang mellem lyskilder og plader kontrolleres før gemning.", + "render.library_rigs": "Designede lysrig", + "render.library_roughness": "Ruhed", + "render.library_save_maps": "Gem teksturkort i denne opsætning", + "render.library_scene_bounds": "Brug registrerede scenegrænser", + "render.library_scenes": "Scenespecifikationer", + "render.library_search": "Søg i bibliotek", + "render.library_select_hint": "Vælg en ressource i biblioteket for at se dens understøttede handlinger og begrænsninger.", + "render.library_selene": "Uigennemsigtige SELENE-udseender", + "render.library_selene_shots": "SELENE-optagelsesspecifikationer", + "render.library_short_blocked": "Ikke tilgængelig", + "render.library_short_native": "Indbygget sammensætning", + "render.library_short_projection": "Understøttet overførsel", + "render.library_short_reference": "Kun reference", + "render.library_short_specification": "Kun specifikation", + "render.library_source_preview": "Indlæs forhåndsvisning af kildekort", + "render.library_source_preview_help": "Designet kildekort til basisfarve. Dette er ikke en renderet eller målt forhåndsvisning af materialet.", + "render.library_specification_only": "Kun specifikation: ikke en komplet, udførbar forudindstilling. Nødvendige effekter, ressourcer og motivroller mangler stadig eksplicit implementering og binding.", + "render.library_specs": "Kun specifikationer", + "render.library_status": "Tilgængelighed", + "render.library_surfaces": "Designede PBR-overflader", + "render.library_target": "Målemne", + "render.library_title": "Bibliotek over renderingsressourcer", + "render.library_unfavorite": "Fjern favorit", + "render.library_uniform_ack": "Brug den ensartede tilnærmelse uden rumlige eller avancerede effekter", + "render.library_usable": "Udførbare overførsler", + "render.library_users": "Bruges af", + "render.light_aim": "Sigt mod", + "render.light_anchor_pending": "Ændring af lysets koordinatsystem understøttes endnu ikke.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Vælg CPU ray tracing eller et eksplicit GPU-studio for at redigere lys. Ældre GPU-studielys forbliver skrivebeskyttede.", + "render.light_behaviour": "ADFÆRD", + "render.light_default_fill": "Fyldlys", + "render.light_default_key": "Hovedlys", + "render.light_default_number": "Lys {number}", + "render.light_default_rim": "Kantlys", + "render.light_delete": "Slet", + "render.light_distance": "Afstand", + "render.light_duplicate": "Duplikér", + "render.light_elevation": "Højdevinkel", + "render.light_gpu_punctual": "Punkt- og spotlys er tilgængelige med CPU ray tracing.", + "render.light_hard_shadow": "Dette lys har hårde skygger; lyskildens blødhed er ikke tilgængelig.", + "render.light_intensity": "Intensitet", + "render.light_missing": "Dette lys findes ikke længere. Vælg et lys i modeltræet.", + "render.light_place_highlight": "Placér efter højlys", + "render.light_place_pending": "Placering efter højlys kræver valg af lys i visningsområdet og understøttes endnu ikke.", + "render.light_position": "POSITION", + "render.light_preview_help": "Live-visningen er uden skygger; arealys bruger en tilnærmelse med centersample. Rendering bruger den fulde lysmodel.", + "render.light_product_centre": "Produktets centrum", + "render.light_size": "Størrelse", + "render.light_softness": "Blødhed", + "render.light_temperature": "Farvetemperatur", + "render.light_temperature_help": "Omtrentlig farvetone for et sort legeme; gemmes som lineær RGB.", + "render.light_type": "Type", + "render.light_type_area": "Arealys", + "render.light_type_point": "Punktlys", + "render.light_type_spot": "Spotlys", + "render.light_type_sun": "Sol", + "render.light_view_only": "Solo og mute påvirker denne visning og dens renderinger; de gemmes aldrig i designet.", + "render.light_world_origin": "Verdens origo", + "render.lighting_aim_point": "Sigtepunkt", + "render.lighting_apply": "Anvend forudindstilling", + "render.lighting_area_samples": "Samples for arealys", + "render.lighting_budget": "Budget", + "render.lighting_budget_help": "Antallene er skrivebeskyttede. Tilføj, duplikér eller slet lys for at ændre dem; metoden sætter grænserne.", + "render.lighting_colour": "Farve", + "render.lighting_consent": "Erstat retningslysene med denne forudindstilling", + "render.lighting_custom": "Brugerdefinerede lys", + "render.lighting_custom_help": "Når du vælger Brugerdefineret, bliver forudindstillingen til redigerbare lys, ét i hver række i modeltræet.", + "render.lighting_direction": "Retning X Y Z", + "render.lighting_direction_help": "Eller skriv en retning mod lyset (X Y Z).", + "render.lighting_distance_unit": " × størrelse", + "render.lighting_edge_u": "Sidevektor U", + "render.lighting_edge_v": "Sidevektor V", + "render.lighting_falloff": "Aftagning", + "render.lighting_falloff_help": "Intensiteten angives ved referenceafstanden.", + "render.lighting_height": "Højde V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Mute dette lys", + "render.lighting_point": "Punktlys", + "render.lighting_preset": "Studioforudindstilling", + "render.lighting_preset_cpu": "Studioforudindstillinger med retningslys kræver CPU ray tracing. GPU-belysning bruger eksplicitte sol- og arealys.", + "render.lighting_preset_row": "Forudindstilling", + "render.lighting_radiance": "Strålingstæthed", + "render.lighting_reference": "Reference", + "render.lighting_review_help": "Erstatter listen over retningslys. Bevarer punkt- og arealys, omgivelser, skygger, kamera og alle andre optagelsesindstillinger.", + "render.lighting_review_title": "Gennemgå forudindstilling for belysning", + "render.lighting_samples_cpu": "Samples for arealys gælder for CPU ray tracing.", + "render.lighting_samples_need_area": "Tilføj et CPU-arealys for at angive samples for arealys.", + "render.lighting_samples_reason": "Samples for arealys skal være 1, 4, 16 eller 64.", + "render.lighting_shadow_cpu": "Skyggeafbryderen kræver CPU ray tracing. GPU path tracing kaster altid skygger.", + "render.lighting_shadows": "Kast skygger", + "render.lighting_shadows_section": "Skygger og sampling", + "render.lighting_shape": "Form", + "render.lighting_solo": "Solo dette lys", + "render.lighting_source": "Kilde", + "render.lighting_source_section": "Lyskilde", + "render.lighting_spot_preserved": "Dette punktlys bevarer sin gemte spotkegle, indtil typen ændres.", + "render.lighting_sun_distance": "Solens retning er uafhængig af afstand og sigtepunkt.", + "render.lighting_unsupported": "understøttes ikke", + "render.lighting_used": "{used} af {limit}", + "render.lighting_width": "Bredde U", + "render.lighting_world_position": "Verden X Y Z", + "render.lights_fixed_to": "Lys fastgjort til", + "render.linear_help": "Bevarer en 32-bit float RGBA-master før støjreduktion, eksponering, tone mapping, filtre og kamerabaggrund. Lineær sRGB/D65; præmultipliceret med dækning. Ekstra hukommelse godkendes eksplicit. Ingen komprimering eller ekstra pass.", + "render.linear_retain": "Bevar rå lineær EXR", + "render.linear_unavailable": "Det valgte færdige billede har ingen bevaret EXR. Slå bevaring til før rendering; en ældre PNG kan ikke genskabe rå strålingstæthed.", + "render.look_add": "Tilføj farvelook", + "render.look_applied": "{name} anvendt", + "render.look_bleach": "Bleach bypass-inspireret", + "render.look_cool": "Kølig", + "render.look_cyanotype": "Cyanotypi-inspireret", + "render.look_faded": "Falmet tryk", + "render.look_material": "Materiale", + "render.look_mono": "Monokrom", + "render.look_pasted": "Look indsat", + "render.look_sepia": "Sepia", + "render.look_strength": "Styrke for look", + "render.look_teal_orange": "Blågrøn / orange", + "render.look_title": "Stak af kreative looks", + "render.look_warm": "Varm", + "render.mapping_scale": "Multiplikator for fysisk gentagelse", + "render.material_absorption": "Absorption R · G · B (pr. mm)", + "render.material_add": "Tilføj eller redigér dette emne", + "render.material_assignments": "Emnetildelinger", + "render.material_binding_enable": "Medtag denne tilsidesættelse for emnet", + "render.material_box": "Akseparallel boks", + "render.material_browse": "Vælg billede…", + "render.material_certification": "Lukket glas bruger geometrisk afstand. Topologi, orientering, overlap og optiske grænser kontrolleres under GPU-forberedelsen; Kontrollér godkender kun anmodningen.", + "render.material_choose_body": "Vælg et emne i scenen", + "render.material_closed": "Lukket massivt mesh", + "render.material_coat": "Klarlak oven på basismaterialet", + "render.material_coat_enable": "Aktivér klarlak", + "render.material_coat_help": "Illustrativ dielektrisk belægning oven på det eksisterende uigennemsigtige udseende. Vægt nul efterlader basen uændret.", + "render.material_color_map": "Basisfarvebillede (sRGB)", + "render.material_color_replace": "Basiskort erstatter emnets farve", + "render.material_custom": "Brugerdefineret illustrativ optik", + "render.material_enable": "Brug disse GPU-tilsidesættelser af materiale", + "render.material_filter": "Filtrér emner i scenen…", + "render.material_filter_help": "Op til 128 matchende emner vises. Præcisér filteret for store scener.", + "render.material_glass": "Glas og optisk absorption", + "render.material_glass_enable": "Brug fysisk glas på dette emne", + "render.material_glass_exclusive": "Glas kan ikke dele emne med klarlak eller runtime-teksturkort i denne renderingsmotor.", + "render.material_glass_help": "Originale illustrative opskrifter. Farveprøver viser transmittans, ikke en renderet forhåndsvisning. Absorption er −ln(lineær transmittans) / referenceafstand; alfa er uændret.", + "render.material_gpu_required": "Disse aktive tilsidesættelser af materiale kræver GPU path tracing. Vælg GPU, eller slå tilsidesættelserne fra, før du kontrollerer eller starter.", + "render.material_ior": "Brydningsindeks (IOR)", + "render.material_limits": "Højst 128 tilsidesættelser af emner, 32 adskilte glasemner, 64 belægninger og 128 teksturbindinger. Identiske kort deler lagerplads; de faktiske grænser for enheden og samlet hukommelse gælder stadig.", + "render.material_map_invalid": "Stier til kort skal være begrænsede lokale stier; normalstyrken skal være et tal mellem 0 og 1.", + "render.material_map_required": "Vælg et farvebillede eller et normalbillede, før du bruger kort.", + "render.material_maps": "Plane farve- og normalkort", + "render.material_maps_enable": "Aktivér kort for dette uigennemsigtige emne", + "render.material_maps_help": "PNG/JPEG op til 1024×1024; 16 MiB kodet i alt. U og V er ortogonale gentagelsesvektorer i mm i verdenskoordinater (højst 128 gentagelser). Farve er sRGB; normaldata bruger OpenGL +Y. Filer registreres ved Kontrollér/Start.", + "render.material_metallic_map": "Metalkort (lineært)", + "render.material_normal_map": "Normalbillede (OpenGL, +Y)", + "render.material_normal_strength": "Normalstyrke (0–1)", + "render.material_origin": "Kortets origo X · Y · Z (mm)", + "render.material_refresh": "Opdatér emner i scenen", + "render.material_refresh_needed": "Scenen er ændret. Opdatér emnelisten, før du tilføjer et mål.", + "render.material_remove": "Fjern denne tildeling", + "render.material_repeat_u": "Én U-gentagelse X · Y · Z (mm)", + "render.material_repeat_v": "Én V-gentagelse X · Y · Z (mm)", + "render.material_revalidate": "Valider dette emne igen", + "render.material_revalidate_needed": "Denne tildeling hører til en tidligere scenerevision. Valider dens præcise emneidentitet igen før rendering.", + "render.material_roughness": "Klarlakkens ruhed (0,045–1)", + "render.material_roughness_map": "Ruhedskort (lineært)", + "render.material_shadow": "Lad lige skyggestråler gå igennem", + "render.material_target_missing": "Emnets ordenstal og navn stemmer ikke længere overens. Fjern denne tildeling, og vælg eksplicit det tilsigtede emne igen.", + "render.material_thickness": "Tykkelse af tynd plade (mm)", + "render.material_thin": "Tynd plade", + "render.material_title": "GPU-billedmaterialer", + "render.material_transient": "Midlertidige optiske tilsidesættelser for denne dokumentfane. De gemmes ikke i designet, renderingsopsætningen eller materialebiblioteket.", + "render.material_v_up": "Tekstur-V peger opad i billedet", + "render.material_weight": "Klarlakkens vægt (0–1)", + "render.method_cpu": "CPU ray tracing", + "render.method_gpu": "GPU path tracing", + "render.method_limit_raster": "Raster bruger den faste uigennemsigtige studieforhåndsvisning. Vælg en eksplicit ray tracing-metode for at få understøttede optiske kontroller.", + "render.method_raster": "CPU-raster", "render.navigation_help": "Kameraet bevæges som i Design (Indstillinger › Navigation). Gem opsætning gemmer det aktuelle kamera.", + "render.nested_enable": "Certificerede indlejrede dielektrika", + "render.nested_help": "Op til 16 omsluttende lag; hvert overordnede lag skal være konvekst, og hvert underordnede skal ligge strengt inde i det. Skæringer, berørende overflader, tynde plader og transmission med lige skygger afvises. Tilstanden med kameraet indeni følger det certificerede hierarki. Opretter ikke en væskeoverflade og fletter ikke berørende CAD-grænser sammen.", "render.opaque_image_summary": "PNG-forhåndsvisning · uigennemsigtig PBR · blandet gennemsigtighed", "render.opaque_preview_help": "Uigennemsigtig PBR og enkle farver; blandet gennemsigtighed. Lysbrydning, HDRI og endelig rendering er endnu ikke tilgængelige.", + "render.optics_remove": "Fjern gemt optik", + "render.optics_replace": "Erstat dette måls gemte optik eksplicit", + "render.optics_save": "Gem denne optik i opsætningen", + "render.optics_saved": "Gemte optiske mål", + "render.optics_saved_help": "Den præcise identitet af definition, feature og forekomst bevares ved genåbning offline. Manglende/erstattede/skjulte mål afvises; fysiske materialer er uændrede. Gem eller kassér numeriske ændringer, før du fjerner et gemt mål.", + "render.outer_half_angle": "Ydre halvvinkel", + "render.output_advanced": "Avanceret", + "render.output_backend_ready": "Klar", + "render.output_check": "Kontrollér", + "render.output_check_failed": "Kontrollér · fejl", + "render.output_check_help": "Kør Kontrollér for at gennemgå denne anmodning uden at starte en rendering.", + "render.output_check_summary": "Kontrollér · Noter: {count}", + "render.output_copy": "Kopiér", + "render.output_copy_report": "Kopiér rapport", + "render.output_destination": "Destination", + "render.output_destination_help": "Vælg destinationsfilen, når du eksporterer det færdige billede.", + "render.output_dimensions": "Størrelse", + "render.output_effects": "Effekter", + "render.output_export": "Eksportér PNG…", + "render.output_exr_unavailable": "Opsætning af EXR-output er ikke tilgængelig her. Lineær EXR-eksport kræver et færdigt GPU-billede med en bevaret lineær master.", + "render.output_fit": "Tilpas", + "render.output_format": "Format", + "render.output_format_status": "PNG er det præcise færdige visningsbillede. Valgfri GPU-EXR bevarer rå scenelineær FLOAT32-strålingstæthed før visningsfiltre. Eksport bruger aldrig forhåndsvisningens cache eller et andet job i stedet.", + "render.output_grading": "Farvegradering", + "render.output_image": "Billede", + "render.output_largest": "maks. {width} × {height}", + "render.output_lock_ratio": "Lås sideforhold", + "render.output_over_budget": "Det ønskede billede overskrider den valgte ressourcegrænse. Vælg eksplicit en kompatibel størrelse eller profil; der sker ingen automatisk nedgradering.", + "render.output_presets_help": "Format, kvalitet og kontrollisten findes i output-editoren.", + "render.output_quality": "Kvalitet", + "render.output_quality_waiting": "venter på kvalitetskontrakten", + "render.output_ratio_refused": "Den sammenkædede dimension overskrider denne metodes størrelsesgrænse. Den forrige størrelse bevares; lås sideforholdet op, eller vælg en mindre værdi.", + "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_ok": "Størrelse OK · {width} × {height} px", + "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", + "render.page_aging": "Ældning og forurening", + "render.page_all": "Alle kontroller", + "render.page_appearance": "Udseende", + "render.page_background": "Baggrunde", + "render.page_camera": "Kamera", + "render.page_diagnostics": "Diagnostik", + "render.page_effects": "Linse- og efterbehandlingseffekter", + "render.page_environment": "Omgivelser", + "render.page_lighting": "Belysning", + "render.page_output": "Output", + "render.page_scene": "Scene", + "render.paint_label": "Mal", + "render.painting_with": "Maler med {name}", + "render.passive_stage": "Grundplan og passiv kulisse", + "render.passive_stage_help": "Afgrænsede passive paneler hører kun til renderede billeder. De tilføjer hverken CAD-emner eller masse. Placeringen er låst efter anvendelse; paneler, der skærer produktets grænser, afvises. CPU leverer ikke diffus bounce-belysning.", "render.perspective": "Perspektivkamera", + "render.physical_acknowledge": "Jeg forstår, at denne delvise reference kræver gennemgang af kvalitetsklasse, tilstand og kilde", + "render.physical_associations": "Fysiske kandidater og roller", + "render.physical_background_help": "Virkeligt passivt gulv og væg, adskilt fra kamerabaggrunden. Cyklorama bruger 24 polygonale strimler; det er ikke en uendelig eller analytisk glat overflade. Erstatter kun den gemte kulisse med dit samtykke.", + "render.physical_copy_plan": "Kopiér plan med kilder og udeladelser", + "render.physical_cyclorama": "Tilpas polygonalt cyklorama", + "render.physical_floor_wall": "Tilpas fysisk gulv og væg", + "render.physical_material_name": "Foreslået navn på referencemateriale", + "render.physical_omissions": "Egenskaber, der ikke kan repræsenteres i det indbyggede kort", + "render.physical_plan_only": "Skrivebeskyttet plan klargjort. Der blev ikke oprettet eller tildelt noget materiale. Behold den udvidede profil og udeladelserne, når du gennemgår det indbyggede kort.", + "render.physical_prepare_plan": "Klargør oprettelsesplan (anvender ikke)", + "render.physical_separate": "Udseende er ikke materialeidentifikation. Det er referencer med kildeangivelse, ikke certificerede effektive egenskaber. Eksisterende masse, fysiske materialer og optik er uændrede.", + "render.physical_unknown": "Der er ikke fastlagt nogen numerisk kandidat. Kvalitetsklasse, sammensætning eller konstruktion skal angives; ukendt er ikke nul.", + "render.pick_body": "Emne", + "render.pick_component": "Komponent", + "render.pick_face": "Flade", + "render.pick_help": "Vælg Emne, Flade eller Komponent. Klik på et tomt område for at rydde; Shift eller Ctrl tilføjer eller skifter. Fremhævninger vises kun i den levende produktvisning.", + "render.pick_label": "Vælg", + "render.pick_up_label": "Hent", + "render.pick_up_prompt": "Klik på et objekt for at hente dets udseende", + "render.pick_waiting": "Valg i produktet understøttes endnu ikke.", + "render.post_contrast": "Kontrast", + "render.post_gain_b": "Blå forstærkning", + "render.post_gain_g": "Grøn forstærkning", + "render.post_gain_r": "Rød forstærkning", + "render.post_help": "Kun kreativ præsentation. RGB-forstærkninger kommer før tone mapping; kontrast, farvemætning og vignettering følger i visningslineært lys. Start rendering opretter nye samples til lystransport. Gradér igen bruger den valgte bevarede rå master uden støjreduktion eller nye stråler.", + "render.post_reset": "Nulstil præsentationsfiltre", + "render.post_saturation": "Farvemætning", + "render.post_title": "Præsentationsfiltre", + "render.post_vignette": "Vignettering", "render.preview": "Integreret forhåndsvisning", "render.preview_help": "Rasterbaseret skygning og blandet gennemsigtighed. Lysbrydning, HDRI og endelig rendering er endnu ikke tilgængelige.", + "render.preview_image_settings_help": "Live-forhåndsvisningen bruger fast studiebelysning. Eksponering og billedbelysning gælder for billedjobbet, når du kontrollerer eller starter.", + "render.preview_lighting_help": "Live-forhåndsvisningen viser kun direkte lys og ingen skygger.", + "render.preview_retry": "Prøv live-forhåndsvisning igen", + "render.preview_unavailable_help": "Live-forhåndsvisningen er ikke tilgængelig. Gemte opsætninger og kontroller til billedjobbet kan stadig bruges. Ændr scenen, eller prøv eksplicit igen; en afvist scene genopbygges ikke ved hvert billede.", + "render.preview_upload": "Vertexdata til live-forhåndsvisning", + "render.preview_upload_help": "Præcise vertexbytes til forhåndsvisningen, ikke samlet GPU- eller proceshukommelse. Geometrien forenkles ikke. Billedjob har deres egen godkendelse og belysning.", + "render.progressive_image_summary": "PNG-forhåndsvisning · PBR og glas · progressive samples", + "render.property_conditions": "Betingelser: {value}", + "render.property_fallback": "egenskab", + "render.property_qualifier": "Kvalifikator: {value}", + "render.property_source": "Kilde: {id} - {locator}", + "render.property_uncertainty": "Usikkerhed: {value}", + "render.property_unknown": "ukendt", + "render.quality_ack": "Bekræft overførslen med fast budget og udeladelserne", + "render.quality_apply": "Brug dette faste sampling-budget", + "render.quality_balanced": "Afbalanceret", + "render.quality_custom": "Brugerdefineret", + "render.quality_draft": "Kladde", + "render.quality_high": "Høj", + "render.quality_projection_help": "Q-forudindstillinger omfatter fremtidige krav. Denne handling overfører kun et understøttet PNG-budget med faste samples og udelader eksplicit adaptivt stop, rå lineært output og uafhængige transmissionsgrænser.", + "render.quality_review": "Gennemgå sampling-budget", + "render.queue_pending": "Der er ingen automatisk produktionskø eller animationsrenderingsmotor i denne brugerflade. Færdige stillbilleder kan stadig eksporteres enkeltvis.", + "render.radial_assign": "Tildel udseende", + "render.radial_compare": "Sammenlign", + "render.radial_distortion": "Radial forvrængning", + "render.radial_fit": "Tilpas til produkt", + "render.radial_save": "Gem optagelse", + "render.recent_looks": "Seneste looks", + "render.regrade_action": "Gradér rå master igen (uden støjreduktion)", + "render.regrade_help": "Opretter en separat PNG fra den valgte færdige rå master med disse præsentationskontroller. Sporer ikke stråler, ændrer ikke den registrerede størrelse, det registrerede kamera eller lys og overskriver ikke EXR-filen. Støjreduktion anvendes ikke igen: Rå masters bevarer ikke dens hjælpedata. Den afledte fil kan stadig eksporteres via de samme jobkontroller.", + "render.regrade_label": "Gradér igen", "render.reload_settings": "Genindlæs gemte indstillinger", + "render.remove_override": "Fjern tilsidesættelse", + "render.replace_all": "Erstat alle", + "render.replace_ground": "Bekræft erstatning af eksisterende kulisse", + "render.replace_label": "Erstat", + "render.replace_saved_surface": "Erstat en eksisterende gemt binding for dette mål", + "render.replace_with": "Erstat med", "render.restore_camera": "Gendan gemt kamera", + "render.result_actual": "100%", + "render.result_actual_help": "Ét billedpixel pr. visningspixel.", + "render.result_actual_size": "100%", + "render.result_capture": "Registreret revision", + "render.result_current": "Den registrerede modelrevision er aktuel.", + "render.result_empty": "Intet færdigt billede endnu. Kontrollér indstillingerne, og start derefter en rendering.", + "render.result_fit": "Tilpas billede", + "render.result_job": "Job", + "render.result_navigation": "Panorering og zoom følger indstillingerne for Navigation. Mellemrumstasten + træk panorerer.", + "render.result_open": "Åbn det færdige billede i hovedvisningen.", + "render.result_preview": "Live-forhåndsvisning", + "render.result_provisional": "Foreløbigt CPU-billede", + "render.result_provisional_help": "Dette kontrolpunkt kan ikke eksporteres. Eksport bruger det seneste færdige resultat; annullering vender tilbage til dette resultat.", + "render.result_reduced_display": "Visningens forhåndsvisning er reduceret til grænsen for visningsområdets enhed/cache. Den bevarede PNG og eksporten bevarer fuld opløsning; indbygget 100%-inspektion er ikke tilgængelig for denne reducerede visning.", + "render.result_retained": "Viser det seneste færdige billede; en nyere rendering har ikke erstattet det.", + "render.result_sequence": "Kontrolpunkt", + "render.result_title": "Renderingsresultat", "render.return_design": "Tilbage til Design", + "render.review_stale": "Denne gennemgang er forældet. Afklar den, eller kontrollér igen, før du anvender den.", + "render.ribbon_aging": "Ældning", + "render.ribbon_backgrounds": "Baggrunde", + "render.ribbon_check": "Kontrollér", + "render.ribbon_check_help": "Validér det præcise aktuelle udkast, som Rendering sender. Ingen reserveløsning i renderingsmotoren, ingen stille ændring af størrelse og ingen udskiftning af ressourcer.", + "render.ribbon_edit_lights": "Redigér lys", + "render.ribbon_effects": "Effekter", + "render.ribbon_environment_help": "HDR-belysning, kamerabaggrunde og fysiske kulisseoverflader er adskilte indstillinger med eksplicit samtykke til udskiftning.", + "render.ribbon_finish_dialog": "Afslut først den aktuelle fil- eller bekræftelsesdialog.", + "render.ribbon_frame": "Billedudsnit", + "render.ribbon_hdr_library": "HDR-bibliotek", + "render.ribbon_job_active": "En rendering kører allerede. Annullér den udtrykkeligt, før du starter en ny.", + "render.ribbon_light_rigs": "Lysrigge", + "render.ribbon_lights_help": "Undersøg designede lysrigge, eller redigér understøttede lys uden at erstatte den aktuelle opsætning, før du udtrykkeligt anvender ændringen.", + "render.ribbon_materials": "Materialer", + "render.ribbon_materials_help": "Åbn den delte udseendeinspektør og det originale overfladebibliotek. Optiske ændringer ændrer aldrig tekniske egenskaber uden varsel.", + "render.ribbon_no_active_job": "Der er ingen aktiv rendering at annullere.", + "render.ribbon_no_result": "Gennemfør en rendering, før du eksporterer det præcise billede.", + "render.ribbon_no_saved_camera": "Gem en optagelse, før du gendanner dens kamera.", + "render.ribbon_saved_camera": "Gemt kamera", + "render.ribbon_shots": "Optagelser", + "render.ribbon_studios": "Studioer", + "render.ribbon_studios_help": "Gennemse implementerede studiokompositioner, og gennemgå deres udskiftninger, før de anvendes. Scener, der kun er koncepter, er fortsat markeret.", + "render.ribbon_surface_maps": "Teksturkort", + "render.sampling_title": "Adaptiv sampling og transmissionssampling", + "render.save_continue": "Gem og fortsæt", "render.save_setup": "Gem opsætning", + "render.save_shot": "Gem optagelse", + "render.saved_maps_runtime_conflict": "Gemte overfladebindinger kan ikke blandes med midlertidige optiske tilsidesættelser eller en HDR, der ikke er gemt. Gem HDR'en, eller fjern de midlertidige input udtrykkeligt.", + "render.saved_surface_remove": "Fjern gemt binding", + "render.saved_surfaces": "Gemte overfladebindinger", + "render.saved_surfaces_help": "Fastgjorte indbyggede teksturkort følger den valgte forekomst i lokale millimeter. Et erstattet eller manglende mål er en fejl og aldrig en automatisk omtildeling.", + "render.scene_all_shots": "Alle optagelser ({count})", + "render.scene_apply": "Anvend gennemgået studio", + "render.scene_batch_reason": "Alle optagelser kræver renderingskøen for gemte optagelser, som endnu ikke er tilgængelig.", + "render.scene_check_now": "Kontrollér nu", + "render.scene_check_passed": "Kontrol bestået · {notes} bemærkninger", + "render.scene_check_stale": "Kontrol forældet · kontrollér igen", + "render.scene_check_unchecked": "Ikke kontrolleret endnu · 0 bemærkninger", + "render.scene_consent": "Bekræft udskiftning af belysning, omgivelser, eksponering og kulisse", + "render.scene_light_summary": "{environment} · {lights} lys", + "render.scene_look_summary": "{looks} udseender · {overrides} tilsidesættelser", + "render.scene_open_check": "Åbn kontrol", + "render.scene_preserved_fields": "Kamera, sampling, dimensioner, optiske bindinger, CAD-geometri og fysiske materialer bevares.", + "render.scene_preserves_design": "Komponér belysning og en passiv kulisse til det synlige motiv. Geometri, fysiske materialer og kameraet forbliver adskilte.", + "render.scene_ready": "Klar til rendering", + "render.scene_reminder": "Scenen bevarer designet. Geometri, tekniske egenskaber og CAD ændres aldrig fra Rendering.", + "render.scene_reminders": "Påmindelser", + "render.scene_renderer_ready": "{renderer} er klar", + "render.scene_replaces": "Erstatter", + "render.scene_review": "Afklar og gennemgå", + "render.scene_review_help": "Gennemgå hele den indbyggede komposition, før den anvendes. Ubundne avancerede SCENE-specifikationer erstattes ikke.", + "render.scene_scope_details": "Kompositionens omfang", + "render.scene_stages": "Kulisser", + "render.search_appearances": "Søg i {count} udseender", + "render.setup_legacy_environment": "Denne ældre opsætning bruger ældre omgivelsesrotation eller -intensitet. Værdierne bevares; konvertér dem udtrykkeligt, før du gemmer et fuldt billedjob.", "render.setup_name": "Opsætningsnavn", + "render.setup_numeric_help": "Gem lagrer den aktive billedmetode og alle dens numeriske indstillinger. Genindlæsning eller valg af en anden opsætning erstatter dette udkast; gendan kameraet separat.", + "render.setup_offline_help": "Du kan redigere og gemme denne metode uden dens renderingsmotor. Kontrollér og Start kræver en tilgængelig vært.", + "render.setup_runtime_unsupported": "Dette fulde job kan endnu ikke gemmes: det indeholder teksturkort eller bindinger til scenens emner. Fjern disse midlertidige input, før du gemmer en portabel opsætning.", + "render.setup_save_missing": "Den gemte renderingskonfiguration kunne ikke findes, efter at kommandoen var afsluttet.", + "render.sharpening": "Skærpelse af detaljer", + "render.shell_tab_appearance": "RENDERINGSUDSEENDE", + "render.shell_tab_camera": "RENDERINGSKAMERA", + "render.shell_tab_lighting": "BELYSNING", + "render.shell_tab_scene": "RENDERINGSSCENE", + "render.shot_aperture": "Blænde", + "render.shot_distance": "Afstand", + "render.shot_dof": "Dybdeskarphed", + "render.shot_duplicate": "Duplikér gemt optagelse", + "render.shot_edit_output": "Redigér i Output", + "render.shot_exposure_reason": "Eksponering kræver CPU ray tracing eller GPU path tracing.", + "render.shot_exposure_section": "Eksponering", + "render.shot_focus_distance": "Fokusafstand", + "render.shot_fov_reason": "Synsfelt kræver perspektivprojektion.", + "render.shot_frame_subject": "Indram motivet", + "render.shot_invalid_camera": "Kameraværdier skal være afgrænsede og ligge inden for intervallet.", + "render.shot_lens": "Linse", + "render.shot_lens_enable_reason": "Aktivér dybdeskarphed for at redigere blænde og fokusafstand.", + "render.shot_lens_gpu_reason": "Dybdeskarphed kræver GPU path tracing.", + "render.shot_lens_perspective_reason": "Dybdeskarphed kræver perspektivprojektion.", + "render.shot_look_selection": "Se mod markering", + "render.shot_manager": "Gemte optagelser", + "render.shot_new_name": "Navn på ny optagelse", + "render.shot_remove": "Fjern gemt optagelse", + "render.shot_remove_help": "Fjerner kun denne gemte opsætning. Kildegeometri og færdige billeder bevares; handlingen kan fortrydes.", + "render.shot_remove_title": "Fjern gemt optagelse?", + "render.shot_rename": "Omdøb gemt optagelse", + "render.shot_saved_only": "Gem først det aktuelle udkast. Duplikering og omdøbning bevarer de præcise gemte indstillinger og ressourcehenvisninger.", + "render.shot_selection_reason": "Vælg et emne eller en flade at sigte mod.", + "render.shot_size": "Størrelse", + "render.shot_target": "Mål", "render.shots_camera_changed": "Kameraet er ændret, siden denne optagelse blev gemt", "render.shots_name_help": "Indtast et unikt navn til den aktuelle optagelse.", "render.shots_no_result": "Ingen rendering endnu", @@ -1415,9 +3182,228 @@ "render.shots_revert": "Gendan", "render.shots_save": "Gem optagelse", "render.shots_update": "Opdater optagelse", + "render.show_shots_label": "Vis optagelsesstrimlen", + "render.size_4k": "4K", + "render.size_4k_uhd": "4K UHD", + "render.size_custom": "Brugerdefineret…", + "render.size_full_hd": "Full HD", + "render.size_hd": "HD", + "render.size_presets": "Forudindstillinger for størrelse", + "render.size_presets_pending": "Kommer med Output-editoren", + "render.size_preview": "Forhåndsvisning", + "render.size_square": "Kvadrat", + "render.sky_ground": "Jord · lineær RGB", + "render.sky_horizon": "Horisont · lineær RGB", + "render.sky_zenith": "Zenit · lineær RGB", + "render.soft_focus": "Blødt fokus", + "render.spot_cone": "Spotkegle", + "render.stage_frame": "Billedudsnit", + "render.stage_layout.appearance_note": "Klik for at anvende på markeringen.", + "render.stage_layout.studio_note": "Klik for at gennemgå, før det anvendes.", + "render.stage_light": "Lys", + "render.stage_look": "Udseende", + "render.stage_orthographic": "Ortografisk", + "render.stage_output": "Output", + "render.stage_panel_count": "Gemte paneler", + "render.stage_panels.acknowledge": "Jeg har forstået udskiftningen og begrænsningerne", + "render.stage_panels.appearance_note": "Klik for at anvende på markeringen. Træk hen på modellen. Hvis intet er markeret, gør et klik Maling klar.", + "render.stage_panels.apply": "Anvend", + "render.stage_panels.apply_studio": "Anvend studio", + "render.stage_panels.badge_blocked": "blokeret", + "render.stage_panels.badge_native": "indbygget", + "render.stage_panels.badge_projection": "overførsel", + "render.stage_panels.badge_reference": "reference", + "render.stage_panels.badge_specification": "kun spec.", + "render.stage_panels.candidate_summary": "Kandidater: {count} værdier med angivet kilde. Kvalitet og finish skal gennemgås.", + "render.stage_panels.captured_bounds": "Brug registrerede scenegrænser", + "render.stage_panels.centre": "Centrum", + "render.stage_panels.depth": "dybde", + "render.stage_panels.environments": "Omgivelser", + "render.stage_panels.extent": "Udstrækning", + "render.stage_panels.fit_save": "Tilpas og gem", + "render.stage_panels.gpu_required": "Kræver GPU path tracing", + "render.stage_panels.keep": "Behold", + "render.stage_panels.kept": "Beholdt", + "render.stage_panels.kept_fields": "Kamera, sampling, dimensioner, optiske bindinger, CAD-geometri og tekniske egenskaber.", + "render.stage_panels.kept_quality": "Metode, kamera, belysning, omgivelser, kulisse, optik, eksponering og tone mapping.", + "render.stage_panels.mine": "Mine", + "render.stage_panels.mine_pending": "Det understøttes endnu ikke at gemme personlige studioer.", + "render.stage_panels.omits": "Udelader", + "render.stage_panels.paint_pending": "Maling og tildeling ved træk understøttes endnu ikke. Vælg et mål for at anvende et udseende.", + "render.stage_panels.partial_reference": "Kandidater: kun værdier med angivet kilde. Gennemgå kvalitet, tilstand og kilder. Ukendte værdier forbliver ukendte.", + "render.stage_panels.plane": "Plan", + "render.stage_panels.prepare_plan": "Forbered oprettelsesplan", + "render.stage_panels.projects": "Projekter", + "render.stage_panels.quality_ack": "Jeg har forstået det faste budget og dets udeladelser", + "render.stage_panels.quality_omissions": "Adaptiv stop og fejlmål, separat transmissionsdybde, regler for Russian roulette, råt lineært output, EXR-master og konvergenscertificering.", + "render.stage_panels.reference": "Reference", + "render.stage_panels.references": "Referencer", + "render.stage_panels.remove_optics": "Fjern optik", + "render.stage_panels.remove_optics_help": "Fjerner den gemte optik fra denne optagelse. Kildegeometrien bevares.", + "render.stage_panels.remove_optics_title": "Fjern den gemte optik?", + "render.stage_panels.remove_shot": "Fjern optagelse", + "render.stage_panels.remove_stage": "Fjern kulisse", + "render.stage_panels.remove_stage_help": "Fjerner de gemte passive paneler fra denne optagelse. Kamerabaggrunden bevares.", + "render.stage_panels.remove_stage_title": "Fjern den gemte kulisse?", + "render.stage_panels.repeat": "Gentag", + "render.stage_panels.replaces": "Erstatter", + "render.stage_panels.replaces_environment": "Omgivelser", + "render.stage_panels.replaces_maps": "Midlertidige teksturkort på det valgte emne; gemt optik kan give konflikt.", + "render.stage_panels.replaces_physical": "Intet. Denne plan er skrivebeskyttet.", + "render.stage_panels.replaces_quality": "Billeddimensioner, faste samples, path-dybde, seed og støjreduktion.", + "render.stage_panels.replaces_rig": "Belysning, omgivelser og kulisse", + "render.stage_panels.replaces_saved_maps": "Gemte teksturkort på det valgte emne; midlertidige tilsidesættelser kan give konflikt.", + "render.stage_panels.replaces_stage": "Gemt grundplan og passive kulissepaneler", + "render.stage_panels.replaces_studio": "Belysning, omgivelser, eksponering og kulisse", + "render.stage_panels.replaces_uniform": "Uigennemsigtigt udseende på det valgte emne. Rumlige teksturkort tilnærmes med ensartede værdier.", + "render.stage_panels.review_environment": "Gennemgå omgivelser", + "render.stage_panels.review_maps": "Gennemgå GPU-teksturkort", + "render.stage_panels.review_physical": "Fysisk referenceplan", + "render.stage_panels.review_quality": "Brug et fast sample-budget", + "render.stage_panels.review_rig": "Tilpas lysrig", + "render.stage_panels.review_saved_maps": "Gennemgå gemte teksturkort", + "render.stage_panels.review_stage": "Gennemgå grundplan og kulisse", + "render.stage_panels.review_studio": "Gennemgå indbygget studio", + "render.stage_panels.review_uniform": "Gennemgå ensartet udseende", + "render.stage_panels.rig_summary": "Designet lysrig · {emitters} lyskilder, {cards} plader", + "render.stage_panels.rigs": "Lysrigge", + "render.stage_panels.save_maps": "Gem teksturkort i denne optagelse", + "render.stage_panels.scope": "Omfang", + "render.stage_panels.search_studios": "Søg i studioer", + "render.stage_panels.search_surfaces": "Søg i overflader og referencer", + "render.stage_panels.source_note": "Designet kilde-teksturkort for basisfarve. Ikke et målt materiale.", + "render.stage_panels.specifications": "Specifikationer · skrivebeskyttet", + "render.stage_panels.studio_ack": "Bekræft udskiftning af belysning, omgivelser, eksponering og kulisse", + "render.stage_panels.studio_note": "Klik for at gennemgå. Belysning, omgivelser og kulisse erstattes først, når du bekræfter. Lysene kan stadig redigeres.", + "render.stage_panels.studios": "Studioer", + "render.stage_panels.subtitle_environment": "Original HDR-omgivelse · hele scenen", + "render.stage_panels.subtitle_maps": "Designede rumlige teksturkort · midlertidig forhåndsvisning", + "render.stage_panels.subtitle_physical": "Skrivebeskyttet plan · intet materiale oprettet eller tildelt", + "render.stage_panels.subtitle_quality": "Fast GPU-budget · fremtidige krav udeladt", + "render.stage_panels.subtitle_rig": "Designet lysrig · lyskilder og passive plader", + "render.stage_panels.subtitle_saved_maps": "Designede teksturkort · gemt i denne optagelse", + "render.stage_panels.subtitle_stage": "Fysisk grundplan og kulisse · tilpasset produktet", + "render.stage_panels.subtitle_studio": "Indbygget studio · gælder hele scenen", + "render.stage_panels.subtitle_uniform": "Ensartet tilnærmelse · ikke et målt materiale", + "render.stage_panels.surfaces": "Overflader", + "render.stage_panels.surfaces_references": "Overflader og referencer", + "render.stage_panels.target": "Mål", + "render.stage_panels.use_budget": "Brug dette budget", + "render.stage_panels.use_maps": "Brug GPU-teksturkort", + "render.stage_panels.use_uniform": "Brug ensartet", + "render.stage_preview_note": "Integreret forhåndsvisning · direkte lys", + "render.state_completed": "Færdigt billede", + "render.state_in_progress": "Billede i gang", + "render.state_model": "Modelforhåndsvisning", + "render.strip_label": "Strimmel", + "render.studio_add_area": "Tilføj arealys", + "render.studio_ambient": "Konstant strålingstæthed i omgivelserne", + "render.studio_ambient_rgb": "Konstant strålingstæthed i omgivelserne (lineær RGB)", + "render.studio_aperture": "Blænderadius", + "render.studio_area": "Arealys", + "render.studio_area_help": "Op til fire ensidige rektangler. Ortogonale halvkanter U og V bestemmer størrelsen; U × V vender mod den oplyste side. Koordinaterne er mm i verdenskoordinater.", + "render.studio_center": "Centrum X, Y, Z (mm)", + "render.studio_denoise": "Reducér støj i det færdige billede", + "render.studio_denoise_help": "Afsluttende kantbevarende filter; fine detaljer kan blive blødere. Glas og skarpe spekulære overflader springer filteret over.", + "render.studio_direction": "Retning mod solen (X, Y, Z)", + "render.studio_environment": "Omgivelser", + "render.studio_environment_bounds": "Omgivelsesrotation skal være afgrænset og ligge inden for ±1.000.000 radianer, og intensiteten skal ligge mellem 0 og 100.", + "render.studio_environment_none": "Intet omgivelseskort", + "render.studio_focus": "Aksial fokusafstand", + "render.studio_half_u": "Halvkant U (mm)", + "render.studio_half_v": "Halvkant V (mm)", + "render.studio_hdr": "Radiance HDR-fil", + "render.studio_hdr_browse": "Vælg HDR…", + "render.studio_hdr_help": "Radiance .hdr, 2:1-panorama, op til 2048 × 1024. EXR er ikke tilgængelig. Farveantagelsen gælder kun, når primærfarverne er uspecificerede.", + "render.studio_hdr_required": "Vælg en lokal .hdr-fil.", + "render.studio_hdr_srgb": "Tolk uspecificerede primærfarver som lineær sRGB", + "render.studio_intensity": "Omgivelsesintensitet", + "render.studio_lens": "Dybdeskarphed", + "render.studio_lens_perspective": "Dybdeskarphed kræver et perspektivkamera.", + "render.studio_lighting": "Studiobelysning", + "render.studio_lighting_enable": "Brug brugerdefineret studiobelysning", + "render.studio_needs_review": "Gennemgå metode og indstillinger", + "render.studio_radiance": "Lineær RGB-strålingstæthed", + "render.studio_ready": "Klar til kontrol", + "render.studio_remove_area": "Fjern arealys", + "render.studio_rotation": "Omgivelsesrotation", + "render.studio_runtime_help": "Numeriske studioindstillinger og kontrollerede HDR-kilder kan gemmes med opsætningen. Optiske tilsidesættelser og materialetilsidesættelser på emner forbliver midlertidige og ændrer ikke gemte udseender.", + "render.studio_saved": "Gemt opsætning / aktuelt kamera", + "render.studio_sky": "Kunstnerisk himmel", + "render.studio_sky_help": "Originalt himmelfarveforløb til belysning; ikke en kalibreret atmosfæremodel.", + "render.studio_sun": "Sol", + "render.studio_sun_radius": "Vinkelradius", + "render.studio_tiles_require_gpu": "Studioer kræver GPU path tracing. Vælg GPU i Output-editoren.", + "render.studio_title": "Renderingsstudio", + "render.studio_tone_map": "Tone mapping", + "render.studios_environments": "Studioer og omgivelser", + "render.subsurface_enable": "Random walk med indre spredning", + "render.subsurface_help": "Kun lukkede dielektrika. Spredning og RGB-absorption er separate koefficienter pr. mm. Lysstier spredes inde i den faktiske geometri og brydes eller reflekteres derefter ved grænsen. Deaktivér direkte skyggetransmission, støjreduktion af overflader og eksternt volumen; brug ét Fresnel-stratum. Ikke en diffusionsshader, et densitetsgitter eller et målt materiale. Tætte medier kan kræve flere samples og større path-dybde.", + "render.subsurface_rate": "Kollisionsrate for spredning", + "render.tone_aces_fitted": "ACES fitted", + "render.tone_reinhard": "Reinhard", + "render.transmission_scope": "GPU-glas understøtter certificerede disjunkte medier. Ubegrænsede indlejrede medier og en støjfri transmissionsestimator leveres ikke.", + "render.transmission_strata": "Betingede Fresnel-strata", + "render.transmission_strata_help": "1 bevarer den oprindelige strøm. 2 eller 4 delstier stratificerer det første valg mellem glasrefleksion og transmission med uændrede love for Snell og absorption. Flere stier koster mere beregning; det er hverken støjreduktion, understøttelse af indlejrede medier eller et løfte om hurtigere rendering.", + "render.tree_add_light": "Tilføj lys", + "render.tree_add_shot": "Tilføj optagelse", + "render.tree_browser": "Renderingstræ", + "render.tree_by_look": "Efter udseende", + "render.tree_by_object": "Efter objekt", + "render.tree_constant": "Konstante omgivelser", + "render.tree_default": "Standard", + "render.tree_directional": "Retningslys", + "render.tree_environment": "Omgivelser", + "render.tree_environment_values": "{rotation}° · {intensity}×", + "render.tree_face_count": "{count} flader", + "render.tree_face_one": "{count} flade", + "render.tree_fixed_studio": "Fast studio", + "render.tree_hdr": "HDR", + "render.tree_lights": "Lys", + "render.tree_lights_pending": "Kommandoer til navngivne lys er endnu ikke tilgængelige.", + "render.tree_mixed": "Blandet", + "render.tree_more": "{count} flere", + "render.tree_mute": "Mute lys", + "render.tree_no_environment": "Ingen omgivelser", + "render.tree_objects": "Objekter", + "render.tree_output": "Output", + "render.tree_point": "Punktlys", + "render.tree_rectangle": "Rektangel", + "render.tree_search": "Søg i objekter", + "render.tree_selection": "Render markering", + "render.tree_shots": "Optagelser", + "render.tree_sky": "Himmel", + "render.tree_solo": "Solo lys", + "render.tree_spot": "Spotlys", + "render.tree_sun": "Sol", + "render.tree_switch": "Skift", + "render.tree_user_one": "{count} objekt", + "render.tree_users": "{count} objekter", "render.unavailable_preview": "Denne opsætning bruger en type forhåndsvisning, der ikke er tilgængelig.", "render.unsaved": "Ændringer i forhåndsvisning er ikke gemt", "render.use_preview": "Brug integreret forhåndsvisning", + "render.volume_albedo": "Spredningsalbedo · lineær RGB", + "render.volume_anisotropy": "Middelcosinus for fasefunktion", + "render.volume_enable": "Aktivér deltagende medium", + "render.volume_extinction": "Ekstinktionskoefficient", + "render.volume_help": "GPU-lystransport inde i én afgrænset boks, med Beer-dæmpning og HG-spredning på kamerastier og indirekte stier. Bruger eksplicit studio-, HDR- eller himmelbelysning, inklusive skyggedæmpning. Positiv fase-cosinus spreder fremad. Glasgrænser og støjreduktion af overflader skal deaktiveres udtrykkeligt. Nul ekstinktion lader lystransporten være uændret. Ikke en fysisk måneatmosfære, et densitetsgitter, volumenemission, spektral lystransport eller en certificeret materialeegenskab.", + "render.volume_max": "Maksimale grænser · mm i verdenskoordinater", + "render.volume_min": "Minimale grænser · mm i verdenskoordinater", + "render.volume_title": "Afgrænset homogent volumen", + "render.wait_add_light": "understøttes endnu ikke: oprettelse af navngivne lys", + "render.wait_batch": "understøttes endnu ikke: renderingskøen for gemte optagelser", + "render.wait_compare": "understøttes endnu ikke: sammenligning af bevarede billeder", + "render.wait_paint": "understøttes endnu ikke: valg af produkt og maling af udseende", + "render.wait_pick_up": "understøttes endnu ikke: valg af produkt og sampling af udseende", + "render.wait_replace": "understøttes endnu ikke: erstatning af alle anvendelser af et udseende", + "render.wait_solo": "understøttes endnu ikke: lysets synlighed og solo-styring", + "render.wear_applies_to": "Gælder for", + "render.wear_layer_count": "{count} lag", + "render.wear_layer_one": "{count} lag", + "render.wear_layers": "Lag", + "render.wear_no_layers": "Ingen lag", + "render.wear_selected_layer": "Valgt lag", + "render.wear_title": "Slid", "sample.bracket.about": "Grundflange med kantflanger og en ombukning efter en regel med 2 mm tykkelse", "sample.bracket.name": "Pladebeslag", "sample.enclosure.about": "Udhullet kasse med læbe, skruebøsninger, understøtning og snapforbindelse", @@ -1444,8 +3430,33 @@ "search.try": "Skriv et kommandonavn, en feature i dette design eller en parameter. Du kører en kommando med værdier ved at skrive dens id og et JSON-objekt: {example}", "search.valid": "Gyldig", "section.off_hint": "Intet plan valgt: OK slår snittet fra.", + "select a direction (an axis, a sketch line or a straight edge) first": "vælg først en retning (en akse, en skitselinje eller en ret kant)", + "select a direction (an axis, a sketch line or a straight edge) for the profile to follow": "vælg en retning (en akse, en skitselinje eller en ret kant), som profilen skal følge", + "select a point on a component": "vælg et punkt på en komponent", + "select a profile, edges or sketch lines first": "vælg først en profil, kanter eller skitselinjer", + "select an axis": "vælg en akse", + "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "vælg først en akse, en ret eller cirkulær kant eller en cylinder-, kegle- eller torusflade", + "select bodies first": "vælg først emner", + "select components first": "vælg først komponenter", + "select corner points, or the two lines of one corner": "vælg hjørnepunkter eller de to linjer i et hjørne", + "select one corner point, or the two lines of one corner": "vælg ét hjørnepunkt eller de to linjer i ét hjørne", + "select sheet edges first": "vælg først kanter på pladen", + "select the bend line (a sketch line) first": "vælg først bukkelinjen (en skitselinje)", + "select the cam's profile (a sketch profile or the cam's edges)": "vælg knastens profil (en skitseprofil eller knastens kanter)", + "select the corner to cut first": "vælg først hjørnet, der skal skæres væk", + "select the corners to round first": "vælg først hjørnerne, der skal afrundes", + "select the fixed face first (a plate's large face)": "vælg først den faste flade (pladens store flade)", + "select the frame sketch's lines first": "vælg først linjerne i rammeskitsen", + "select the path (sketch lines across the edge)": "vælg banen (skitselinjer på tværs af kanten)", + "select the rim face first": "vælg først randfladen", + "select the sheet edge first": "vælg først pladekanten", + "select two components": "vælg to komponenter", + "set by the sketch's lines": "bestemt af skitsens linjer", "settings.display_quality_note": "Gælder kun visningen. Design og eksport påvirkes ikke.", + "settings.double_click_components": "Dobbeltklik på en komponent for at aktivere den eller for at redigere en linket komponent på stedet", + "settings.frame_rate_details_note": "Tilføjer en graf over de seneste billeders tider med de tegnede trekanter oven på, et histogram over billedtider og det, GPU'en tegnede. Klik på billedfrekvensen i visningen for at vise eller skjule dem.", "settings.frame_rate_note": "Viser billeder pr. sekund nederst til højre i modelvisningen.", + "settings.insert_lists_open_designs": "Indsæt design viser først de design, der er åbne i andre faner", "settings.measure_axis_distances": "Vis X/Y/Z-afstande", "settings.measure_axis_distances_help": "Vis afstanden langs X-, Y- og Z-aksen sammen med den samlede afstand i Mål.", "settings.msaa_applies_on_restart": "Kantudjævning træder i kraft, næste gang SolidCraft starter", @@ -1456,22 +3467,41 @@ "settings.reset_intro": "Disse indstillinger virker ikke på denne computer og ville forhindre SolidCraft i at starte. De er derfor ændret:", "settings.reset_title": "Indstillinger blev ændret", "settings.supersampling_note": "Tegner visningen med højere opløsning og nedskalerer den: Jævnere kanter, linjer og gitter på skærme under 4K. Det kræver GPU-tid: ca. 2,25× så mange pixels ved 1,5× og 4× ved 2×.", + "settings.update_changed_links": "Opdatér ændrede linkede design, når en fane vender tilbage", + "settings.window_system_details": "Detaljer", + "settings.window_system_note_now": "Træder i kraft efter en genstart. Nu: {now}.", + "settings.window_system_wayland_limited": "Nogle funktioner er begrænsede på native Wayland.", + "sheet.flange_type_base": "Basis", + "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)", "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", "shortcut.unpin": "Frigør", + "sketch is missing": "skitsen mangler", "sketch.degrees_of_freedom": "Frihedsgrader: {count}", + "sketch.dimension_added_driven": "Skitsen bestemmer allerede dette mål, så det blev tilføjet som afledt mål (reference)", + "sketch.dimension_applied": "Mål anvendt", + "sketch.dimension_applied_defined": "Mål anvendt; parameteren `{name}` er defineret", + "sketch.dimension_applied_renamed": "Mål anvendt; parameter med navnet `{name}`", "sketch.dof_left": "{count} frihedsgrader tilbage", "sketch.dof_left_one": "{count} frihedsgrad tilbage", "sketch.draw_curves": "Tegn som {linetype}", "sketch.make_selection": "Konvertér markeringen til {linetype}", "sketch.open_ends": "Åbne ender: {count}", + "sketch.status_conflict": "bindinger er i konflikt", + "sketch.status_fully_constrained": "fuldt bundet", "sketch.untied_ends": "Ender uden binding: {count}", "sketch_panel.attached": "Tilknyttet {body} / {path}", "sketch_panel.author": "Forfatterplacering: {path}", "sketch_panel.dimension_hint": "Angiv en værdi eller et udtryk, og tryk derefter på OK.", "sketch_panel.move_hint": "Angiv afstande og en vinkel, og tryk derefter på OK.", + "sketch_panel.plane_angle": "{plane}, drejet med {angle}", + "sketch_panel.plane_fixed": "Et fast plan", + "sketch_panel.plane_flipped": "{plane}, fra dens anden side", + "sketch_panel.plane_offset": "{plane}, forskudt med {distance}", + "sketch_panel.plane_origin": "Planet {plane}", "sketch_panel.redefine_title": "{command}: {name}", "sketch_panel.sheet_attachment": "Tilknyttet en plan flade på et pladeemne; den følger panelet, når pladen ændres.", "sketch_panel.text_hint": "Redigér teksten og dens placering, og tryk derefter på OK.", @@ -1488,29 +3518,74 @@ "sketch_text.path_hint": "En kurve i skitsen, som teksten følger: Klik her og derefter på kurven", "sketch_text.use_font_file": "Brug skrifttypefilen {path}", "solid": "massivt emne", + "status.editing": "Redigerer {name}", + "status.navigation": "Musetildeling: {mapping}", + "status.navigation_tip": "Hvordan musen styrer visningen. Klik for at vælge en anden tildeling", + "status.under_constrained": "Ikke fuldt bundet ({count} frihedsgrader)", + "status.under_constrained_one": "Ikke fuldt bundet ({count} frihedsgrad)", + "status.units_tip": "Designets enheder. Klik for at ændre dem", + "the angle must be a number": "vinklen skal være et tal", + "the curves must be in one sketch": "kurverne skal være i samme skitse", + "the direction must be an axis, a sketch line or a straight edge": "retningen skal være en akse, en skitselinje eller en ret kant", + "the duration must be a number of seconds, 0.001 or more": "varigheden skal være et antal sekunder, 0,001 eller mere", + "the line's sketch is gone": "linjens skitse findes ikke længere", + "the lines must be in one sketch": "linjerne skal være i samme skitse", + "the lines must be in one sketch, not the section's": "linjerne skal være i samme skitse, ikke tværsnittets", + "the parts library is empty": "biblioteket med standarddele er tomt", "the parts library is not available": "biblioteket med standarddele er ikke tilgængeligt", + "the path curves must be in one other sketch": "banekurverne skal være i én anden skitse", + "the path's sketch is gone": "banens skitse findes ikke længere", + "the position must be a number": "positionen skal være et tal", + "the preview failed": "forhåndsvisningen mislykkedes", + "the profiles and curves must be in one sketch": "profilerne og kurverne skal være i samme skitse", + "the reference face must be on the edges' body": "referencefladen skal være på kanternes emne", + "the scale must be above 0": "skaleringen skal være over 0", + "the section's profiles must be in one sketch": "tværsnittets profiler skal være i samme skitse", + "the two origins are on the same component": "de to nulpunkter er på samme komponent", "thread.automatic_size": "Automatisk størrelse", "thread.designation": "Betegnelse", + "thread.direction": "Gevindretning", "thread.iso_metric": "ISO-metrisk", + "thread.left_hand": "Venstregænget", + "thread.right_hand": "Højregænget", "thread.select_size": "Vælg størrelse", "timeline.delete_after_marker_one": "Slet 1 feature efter historikmarkøren", "timeline.delete_after_marker_other": "Slet {count} features efter historikmarkøren", + "timeline.delete_folders_tip": "Behold deres features og indlejrede mapper i historikken", + "timeline.delete_folders_with_features_tip": "Slet mapperne med alle features i dem og deres indlejrede mapper", "timeline.delete_many": "Slet {count} valgte features", + "timeline.edited_rolled_back": "{name} er ændret, men ligger efter historikmarkøren og bliver derfor ikke bygget: Flyt markøren forbi den for at se resultatet", + "timeline.feature_error": "Fejl: {message}", + "timeline.feature_hint": "Dobbeltklik for at redigere · træk for at ændre rækkefølgen · højreklik for mere", + "timeline.feature_rolled_back": "Rullet tilbage: efter historikmarkøren og derfor ikke bygget", + "timeline.feature_warning": "Advarsel: {message}", "timeline.group": "Ny featuremappe", "timeline.move_many": "Flyt elementer i tidslinjen", "timeline.move_to_group": "Flyt features til mappe", "timeline.rename_group": "Omdøb featuremappe", "timeline.reorder_many": "Skift rækkefølge på features", + "timeline.suppress_features": "Undertryk features", + "timeline.suppress_folders_tip": "Undertryk eller ophæv undertrykkelsen af alle features i mapperne og deres indlejrede mapper", "timeline.ungroup": "Fjern featuremapper", + "timeline.unsuppress_features": "Ophæv undertrykkelse af features", "to the face": "til fladen", "to the next face": "til næste flade", "toolbar.customize_note": "Fanen {tab}. Markér en kommando for at give den en værktøjslinjeknap. Du kan også trække en række fra et panels rullemenu over på værktøjslinjen, trække en knap af værktøjslinjen for at fjerne den eller bruge nålen på en rullemenurække.", "toolbar.image": "BILLEDE", "toolbar.render": "RENDERING", + "toolbar.render_appearance": "UDSEENDE", + "toolbar.render_camera": "KAMERA", + "toolbar.render_environment": "OMGIVELSER", + "toolbar.render_lighting": "BELYSNING", + "toolbar.render_lights": "LYS", + "toolbar.render_result": "RESULTAT", "toolbar.render_scene": "SCENE", + "toolbar.render_stage": "OPSTILLING", + "toolbar.render_studio": "STUDIO", "trace.points": "Punkter langs banen: {count}", "trace.stopped_by_contact": "Slutter ved prøve {sample}: {a} ville gå gennem {b}", "trace.stopped_by_loop": "Slutter ved prøve {sample}: Løkken gennem {joint} kan ikke lukkes yderligere", + "trim by sketch curves, or by one plane or face": "trim med skitsekurver eller med ét plan eller én flade", "ui.1_degree_of_freedom": "1 frihedsgrad", "ui.3d_mouse_axes_reassigned": "Akserne er omtildelt i Indstillinger, og det gælder stadig oven på denne kalibrering.", "ui.3d_mouse_button": "Knap {number}", @@ -1564,6 +3639,10 @@ "ui.3d_mouse_offer": "Kalibrering af din 3D-mus retter ombyttede eller vendte akser og indstiller dens hastighed og fornemmelse.", "ui.3d_mouse_offer_never": "Vis ikke igen", "ui.3d_mouse_offer_swap": "Skift til Hidraw (direkte driver)", + "ui.3d_mouse_orbit": "Rotér visning", + "ui.3d_mouse_orbit_classic": "Klassisk", + "ui.3d_mouse_orbit_tip": "Sporet: 3D-musen drejer visningen omkring den del af modellen, der er midt i visningen, når drejningen begynder, markeret med en svag kugle. Klassisk: omkring visningens centrum", + "ui.3d_mouse_orbit_traced": "Sporet", "ui.3d_mouse_reading": "Aflæser", "ui.3d_mouse_reset_axes": "Nulstil aksetildeling", "ui.3d_mouse_reset_view": "Nulstil visning", @@ -1600,6 +3679,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Drej knoppen mod uret, hold den, og slip.", "ui.3d_mouse_switch_cap": "Kalibrer som vend tilbage til midten", "ui.3d_mouse_switch_trackball": "Kalibrér som trackball", + "ui.3d_mouse_use_direct": "Brug direkte driver", + "ui.3d_mouse_use_direct_tip": "Læs 3D-musen direkte (hidraw) i stedet for gennem spacenavd.", "ui.3d_mouse_view_axis": "Visningsakse", "ui.3d_mouse_view_off": "Fra", "ui.3d_mouse_view_off_tip": "3D-musedriverens profil slår denne akse fra, så dens bevægelse flytter ikke visningen. Vælger du en anden akses bevægelse her, bytter de to plads, og den bevægelse flytter ikke længere visningen.", @@ -1625,6 +3706,8 @@ "ui.agent_chat_allowed": "Tilladt", "ui.agent_chat_answered": "Svar: {option}", "ui.agent_chat_approval": "Tillad agenten at gøre dette?", + "ui.agent_chat_attach_selection": "Vedhæft markering", + "ui.agent_chat_attach_selection_tip": "Send det, der er markeret i modellen (flader, kanter, features, emner), sammen med beskeden, så agenten ved, hvad du mener", "ui.agent_chat_attach_view": "Vedhæft visning", "ui.agent_chat_attach_view_tip": "Send et billede af den aktuelle visning sammen med beskeden", "ui.agent_chat_attachable": "Billeder, PDF-filer, tekst og CAD-filer", @@ -1637,11 +3720,18 @@ "ui.agent_chat_auth_title": "Log ind hos agenten", "ui.agent_chat_cancel": "Annullér", "ui.agent_chat_changes": "Ændringer: {n}, ét fortrydelsestrin", + "ui.agent_chat_changes_n": "{n} ændringer", "ui.agent_chat_changes_of": "{n}/{limit} ændringer", + "ui.agent_chat_choose_model": "Vælg en model", "ui.agent_chat_close": "Luk", "ui.agent_chat_command": "Agentkommando", "ui.agent_chat_connect": "Forbind", "ui.agent_chat_copy": "Kopiér", + "ui.agent_chat_ctx_bodies": "Emner", + "ui.agent_chat_ctx_elements": "Elementer", + "ui.agent_chat_ctx_features": "Features", + "ui.agent_chat_ctx_more": "+{n} flere", + "ui.agent_chat_ctx_other": "Andet", "ui.agent_chat_custom_hint": "Ethvert program, der bruger Agent Client Protocol via stdin og stdout. Det køres uden en kommandoskal.", "ui.agent_chat_denied": "Afvist", "ui.agent_chat_deny": "Afvis", @@ -1677,12 +3767,15 @@ "ui.agent_chat_import_failed": "{name}: Importen mislykkedes: {error}", "ui.agent_chat_intro": "Chat med en kodeagent, du allerede bruger, eller en modeltjeneste, du vælger. Den arbejder på det åbne design gennem SolidCrafts værktøjer: Ændringer vises undervejs og fortrydes som ét trin pr. besked. Hvad den må gøre uden at spørge, angives i Indstillinger > AI.", "ui.agent_chat_key_in_env": "nøgle angivet", + "ui.agent_chat_last_missing": "Agenten, der sidst blev brugt ({agent}), er ikke længere tilgængelig. Vælg en agent, og tryk derefter på Prøv igen.", "ui.agent_chat_left": "Venstre", "ui.agent_chat_limit": "Agenten nåede grænsen på {limit} ændringer for én besked. Send en ny besked for at lade den fortsætte.", + "ui.agent_chat_login_found": "login fundet", "ui.agent_chat_minimize": "Minimér til en stribe", "ui.agent_chat_mode_all": "Alt", "ui.agent_chat_mode_ask": "Spørg", "ui.agent_chat_mode_edits": "Ændringer", + "ui.agent_chat_model_no_tools": "{model} kan ikke bruge SolidCrafts værktøjer og kan derfor kun chatte. Vælg en model med understøttelse af værktøjer.", "ui.agent_chat_model_tip": "Modellen, som agenten bruger. Dit valg huskes for denne agent.", "ui.agent_chat_new": "Ny chat", "ui.agent_chat_new_confirm": "Start en ny chat? Den aktuelle chat ryddes.", @@ -1691,6 +3784,7 @@ "ui.agent_chat_no_control": "Agentchattens endepunkt startede ikke, så agenten kan ikke få adgang til designet.", "ui.agent_chat_no_images": "Denne agent kan ikke modtage billeder", "ui.agent_chat_no_picture": "Der er intet billede i udklipsholderen", + "ui.agent_chat_no_tool_call": "Modellen brugte ikke SolidCrafts værktøjer, så designet blev ikke ændret. Vælg en model, der understøtter værktøjskald; se Lokale modeller i guiden til agentchatten.", "ui.agent_chat_none_found": "Ingen kodeagent fundet. Installér en af disse, og tryk derefter på Opdatér:", "ui.agent_chat_none_found_short": "Ingen agent fundet", "ui.agent_chat_not_installed": "Ikke installeret", @@ -1700,12 +3794,17 @@ "ui.agent_chat_paste_picture": "Indsæt et billede", "ui.agent_chat_permission": "Agenten beder om at køre: {title}", "ui.agent_chat_pick": "Agent", + "ui.agent_chat_pick_context": "Vælg kontekst", + "ui.agent_chat_pick_context_tip": "Vælg flader, kanter, features og emner i modellen, som agenten skal se på, og tryk derefter på OK", "ui.agent_chat_placeholder": "Bed agenten om at ændre designet…", "ui.agent_chat_placeholder_busy": "Sæt en besked i kø til agenten er færdig (Ctrl+Enter: send nu)", "ui.agent_chat_pop_out": "Åbn i eget vindue, som du kan flytte til en anden skærm", "ui.agent_chat_previous_picture": "Forrige billede", "ui.agent_chat_privacy": "Intet sendes andre steder end til den agent, du kører, under din egen konto.", "ui.agent_chat_privacy_note": "Chatten sender selv intet nogen steder: Agenten, du kører, kommunikerer med sin egen tjeneste under din egen konto. SolidCraft ser eller gemmer aldrig en nøgle eller adgangskode.", + "ui.agent_chat_provider_models": "{count} modeller", + "ui.agent_chat_provider_other": "Andet", + "ui.agent_chat_provider_tip": "Udbyderen, hvis modeller den næste liste viser.", "ui.agent_chat_queue": "Sæt i kø", "ui.agent_chat_queue_full": "For mange beskeder venter: Fjern en, eller vent på agenten.", "ui.agent_chat_queued": "I kø", @@ -1715,6 +3814,7 @@ "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_retry": "Prøv igen", "ui.agent_chat_right": "Højre", "ui.agent_chat_save": "Gem", "ui.agent_chat_send": "Send", @@ -1727,6 +3827,7 @@ "ui.agent_chat_state_auth": "Login kræves", "ui.agent_chat_state_busy": "Arbejder…", "ui.agent_chat_state_exited": "Agent stoppet", + "ui.agent_chat_state_failed": "Forbindelsen mislykkedes", "ui.agent_chat_state_off": "Ikke forbundet", "ui.agent_chat_state_ready": "Klar", "ui.agent_chat_state_starting": "Starter…", @@ -1734,6 +3835,7 @@ "ui.agent_chat_stop_tip": "Stop agenten og alt, hvad den har startet", "ui.agent_chat_stop_turn_tip": "Stop agenten (Esc). Beskeder i kø bliver i køen.", "ui.agent_chat_turn_limit": "Ændringer pr. besked", + "ui.agent_chat_turn_unlimited": "Ubegrænset", "ui.agent_chat_undo_changes": "Fortryd disse ændringer", "ui.agent_chat_undo_label": "Agentchat: {message}", "ui.agent_chat_undone": "(fortrudt)", @@ -1803,6 +3905,9 @@ "ui.ai_preset": "Forudindstilling", "ui.ai_protocol": "Protokol", "ui.ai_remove": "Fjern", + "ui.ai_request_timed_out": "Modellen brugte mere end {seconds} s. Du kan hæve tidsgrænsen for anmodninger til dette endepunkt under Indstillinger › Assistent.", + "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_settings": "AI-indstillinger…", "ui.ai_show_features": "Vis AI-funktioner", @@ -1817,9 +3922,27 @@ "ui.ai_test_no_model": "Forbindelse oprettet: {count} modeller, men ikke {model}", "ui.ai_test_ok": "Forbindelse oprettet: {count} modeller", "ui.ai_testing": "Tester…", + "ui.ai_tool_arguments": "Argumenter", + "ui.ai_tool_details": "Detaljer", "ui.ai_tool_done": "færdig", "ui.ai_tool_failed": "mislykkedes", + "ui.ai_tool_result": "Resultat", "ui.ai_tool_running": "kører", + "ui.ai_tool_sum_bodies_one": "{count} emne", + "ui.ai_tool_sum_bodies_other": "{count} emner", + "ui.ai_tool_sum_by": "med {value}", + "ui.ai_tool_sum_edges_one": "{count} kant", + "ui.ai_tool_sum_edges_other": "{count} kanter", + "ui.ai_tool_sum_faces_one": "{count} flade", + "ui.ai_tool_sum_faces_other": "{count} flader", + "ui.ai_tool_sum_features_one": "{count} feature", + "ui.ai_tool_sum_features_other": "{count} features", + "ui.ai_tool_sum_measured": "Målte {what}", + "ui.ai_tool_sum_more": "{steps} og {count} flere", + "ui.ai_tool_sum_radius": "r {value}", + "ui.ai_tool_sum_saved": "Gemte {file}", + "ui.ai_tool_sum_screenshot": "Skærmbillede", + "ui.ai_tool_sum_then": "{first}, derefter {next}", "ui.ai_tool_unfold": "Klik for at vise svaret", "ui.ai_tool_waiting": "venter", "ui.ai_toolbar_button": "Vis Assistent-knappen på programlinjen", @@ -1849,6 +3972,9 @@ "ui.blocked": "Blokeret", "ui.bodies": "Emner", "ui.bodies_all_if_none": "Emner (alle, hvis ingen er valgt)", + "ui.body_kind_mesh": "Meshlegeme", + "ui.body_kind_solid": "Massivt legeme", + "ui.body_kind_surface": "Overfladelegeme", "ui.bottom": "Nedenfra", "ui.box": "Boks", "ui.break": "Bryd", @@ -1861,6 +3987,8 @@ "ui.cancel": "Annullér", "ui.canvases": "Referencebilleder", "ui.center": "Centrum", + "ui.center_arc_follows_cursor": "Bue med centrum følger markørens retning", + "ui.center_arc_follows_cursor_tip": "En bue med centrum løber med eller mod uret, efter hvordan markøren bevæger sig rundt om dens centrum. Fra: Altid mod uret fra start.", "ui.centerline": "Centerlinje", "ui.chain": "Kæde", "ui.chamfer": "Affasning", @@ -1869,6 +3997,7 @@ "ui.circle": "Cirkel", "ui.circular_pattern": "Cirkulært mønster", "ui.clear": "Ryd", + "ui.clear_print_face": "Ryd printflade", "ui.clear_recent": "Ryd seneste", "ui.clear_the_selection": "Ryd markeringen", "ui.click_selects_whole_bodies": "Klik vælger hele emner", @@ -1903,6 +4032,15 @@ "ui.constant": "Konstant", "ui.constraints_conflict": "Bindinger er i konflikt", "ui.construction": "Konstruktionsgeometri", + "ui.construction.angle_hint": "Hjælpelinje: Klik på et punkt, og skriv derefter en vinkel eller vælg en retning", + "ui.construction.bisector_hint": "Halveringslinje: Vælg to rette linjer, der skærer hinanden, og vælg derefter den ønskede løsning", + "ui.construction.common_hint": "Fælles tangent: Vælg to cirkler/buer, og vælg derefter den ønskede løsning", + "ui.construction.contact_hint": "Tangent: Vælg en cirkel/bue eller en åben spline/keglesnit, og vælg derefter røringspunktet (frie kurver: endepunkter)", + "ui.construction.external_hint": "Tangent: Vælg en cirkel eller bue og et eksternt punkt, og vælg derefter den ønskede løsning", + "ui.construction.free_hint": "Hjælpelinje: Klik på to punkter; højreklik for at starte forfra", + "ui.construction.point_hint": "Hjælpelinje: Klik på det punkt, linjen skal gå igennem", + "ui.construction.reference_hint": "Hjælpelinje: Vælg en ret linje og derefter et punkt; højreklik for at skifte reference", + "ui.construction.ribbon_label": "Hjælpelinjer", "ui.context_menu": "Genvejsmenu", "ui.continuity_g0": "G0-knæk", "ui.continuity_g1": "G1-tangentkontinuitet", @@ -1911,6 +4049,8 @@ "ui.copy": "Kopiér", "ui.corner": "Hjørne", "ui.corners": "Hjørner", + "ui.cosmetic_thread_markings": "Kosmetiske gevindmarkeringer", + "ui.cosmetic_thread_markings_help": "Vis skematiske markeringer på cylindriske vægge med gevind. Ændrer kun visningen, aldrig designet eller eksporterne.", "ui.counterbore": "Cylindrisk forsænkning", "ui.countersink": "Konisk forsænkning", "ui.create": "Opret", @@ -1941,8 +4081,11 @@ "ui.design_properties.part_number": "Emnenummer", "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revision", + "ui.design_saved": "Gemte {path}", "ui.diameter": "Diameter", "ui.dimension": "Målsætning", + "ui.direct_interaction": "Direkte interaktion", + "ui.direct_interaction_note": "Arbejd direkte på modellen i visningsområdet. Tryk på en plan flade eller en profil, og træk: Op ekstruderer, ned i et emne skærer, og sidelæns bevægelse sætter trinstørrelsen", "ui.direction": "Retning", "ui.display_quality": "Visningskvalitet", "ui.display_quality_coarse": "Grov", @@ -1964,12 +4107,39 @@ "ui.easyprint_uploading": "Uploader til Printables…", "ui.edit": "Redigér", "ui.editJointLimits": "Redigér ledgrænser", + "ui.edit_in_place": "Redigér på stedet", + "ui.edit_in_place_after_turn": "Redigeringen afsluttes, når agentens tur er slut", + "ui.edit_in_place_agent_busy": "Agenten arbejder stadig i dette design; prøv igen, når dens tur er slut", + "ui.edit_in_place_already_queued": "En redigering venter allerede på, at agentens tur slutter", + "ui.edit_in_place_assembly_closed": "{name} er ikke længere åben", + "ui.edit_in_place_banner": "Redigerer i {name}", + "ui.edit_in_place_busy": "Afslut eller annullér først det, der er åbent", + "ui.edit_in_place_none": "Intet linket design redigeres på stedet", + "ui.edit_in_place_not_saved": "Delen har ændringer, der ikke er gemt; gem den for at afslutte redigeringen", + "ui.edit_in_place_open_elsewhere": "{name} er åben i et andet SolidCraft", + "ui.edit_in_place_opens_after_turn": "Delen åbnes, når agentens tur er slut", + "ui.edit_in_place_refresh_dropped": "Samlingen blev ændret, mens Opdatér forbindelser kørte; kør den igen", + "ui.edit_in_place_refresh_stopped": "Opdatér forbindelser blev stoppet, før den var færdig", + "ui.edit_in_place_refreshing": "Opdaterer forbindelser i {name}…", + "ui.edit_in_place_save_first": "Gem først samlingen: Dens linkede design findes ud fra dens fil", + "ui.edit_in_place_save_waits": "Opdatér forbindelser kører i {name}; gem den, når den er færdig", + "ui.edit_in_place_saved_elsewhere": "Delen blev gemt i en anden fil; samlingen linker stadig til {name}", + "ui.edit_in_place_wait": "Vent, til det, der kører i designet, er færdigt", "ui.edit_section_analysis": "Redigér snitanalyse", "ui.ellipse": "Ellipse", + "ui.emboss_wrap_note": "Ortogonale linjeprofiler, der tangerer én understøttet cylinder, vikles automatisk om. Vælg en væg for at vælge et mål.", "ui.end_radius": "Slutradius", "ui.end_scale": "Skalering ved slutningen", "ui.entities": "Elementer", "ui.equal": "Ens", + "ui.equation_end": "Slut", + "ui.equation_explicit": "Eksplicit", + "ui.equation_form": "Form", + "ui.equation_note": "Variablen er et almindeligt tal: sin(t) tager den i radianer. Et resultat uden enhed er i designets enhed.", + "ui.equation_parametric": "Parametrisk", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ækvivalent radius", "ui.expand": "Fold ud", "ui.exportDxf": "Eksportér DXF…", @@ -2001,10 +4171,12 @@ "ui.flip": "Vend", "ui.format": "Format", "ui.frame_rate": "Billedfrekvens", + "ui.frame_rate_details": "Detaljer om billedfrekvens", "ui.front": "Forfra", "ui.fully_constrained": "Fuldt bundet", "ui.function": "Funktion", "ui.general": "Generelt", + "ui.gpu_memory_lowered": "Grafikkortet er løbet tør for hukommelse: Visningen tegnes i lavere kvalitet.", "ui.graphics": "Grafik", "ui.graphics_custom": "Brugerdefineret", "ui.graphics_performance": "Ydeevne", @@ -2033,9 +4205,12 @@ "ui.heading_design": "DESIGN", "ui.heading_draft": "SLIPVINKEL", "ui.heading_edit": "REDIGÉR", + "ui.heading_equation_curve": "LIGNINGSKURVE", "ui.heading_extrude": "EKSTRUDERING", "ui.heading_fillet": "AFRUNDING", + "ui.heading_finish_editing": "AFSLUT REDIGERING", "ui.heading_finish_sketch": "AFSLUT SKITSE", + "ui.heading_form": "FORM", "ui.heading_front": "FORFRA", "ui.heading_hole": "HUL", "ui.heading_home": "HJEM", @@ -2092,6 +4267,8 @@ "ui.hideOrigin": "Skjul nulpunkt", "ui.hidePlanes": "Skjul planer", "ui.hideProfile": "Skjul profil", + "ui.hide_origin_by_default": "Skjul nulpunktet som standard", + "ui.hide_origin_note": "Nye designs starter med mappen Nulpunkt skjult. Et design beholder sin egen indstilling for nulpunktet, når du har vist eller skjult det.", "ui.history_back": "Gå ét trin tilbage", "ui.history_below_browser": "Under browseren", "ui.history_edge_hint": "dobbeltklik på kanten", @@ -2102,7 +4279,12 @@ "ui.history_marker_tip": "Historikmarkør: Træk for at rulle modellen tilbage; nye features indsættes her", "ui.history_names": "Navne", "ui.history_play": "Afspil til slutningen", + "ui.history_play_button_hide": "Skjul afspilningsknappen", + "ui.history_play_button_show": "Vis afspilningsknappen", + "ui.history_play_finish_action": "Afslut den aktuelle handling, før historikken afspilles", + "ui.history_play_roll_back": "Flyt historikmarkøren tilbage, før du afspiller", "ui.history_show_names": "Vis navne", + "ui.history_sketch_listed_under": "Vises under {feature}", "ui.history_title": "Historik", "ui.history_toggle_names": "Vis eller skjul featurenavne", "ui.hole": "Hul", @@ -2110,14 +4292,21 @@ "ui.hole_type": "Hultype", "ui.home": "Hjem", "ui.homeView": "Hjemmevisning", + "ui.horizontal_constraint": "Vandret binding", "ui.horizontal_vertical": "Vandret/lodret", "ui.import_ellipsis": "Importér…", "ui.infinite": "Uendelig", "ui.insert": "Indsæt", + "ui.insert_design.browse": "Gennemse…", + "ui.insert_design.open_designs": "Åbne designs", + "ui.insert_design.saved_file": "{name} har ændringer, der ikke er gemt, så dens gemte fil blev indsat. Gem den, og kør derefter Opdatér forbindelser for at hente ændringerne.", + "ui.insert_design.unsaved": "Ikke-gemte ændringer", "ui.inspect": "Inspicér", "ui.intersect": "Fællesvolumen", "ui.isolate": "Isolér", "ui.join": "Foren", + "ui.joint_play": "▶ Afspil", + "ui.joint_stop": "■ Stop", "ui.joints": "Led", "ui.keyboard_shortcuts": "Tastaturgenveje", "ui.language": "Sprog", @@ -2135,6 +4324,7 @@ "ui.loft": "Loft", "ui.look_at": "Se på", "ui.look_at_anim_desc": "Aksevisning, Se på, Hjem og Tilpas", + "ui.major_radius": "Større radius", "ui.manipulator_free_alt": "Brug også Alt/Option til at trække frit", "ui.manipulator_free_alt_tip": "Slået til: Hold Alt/Option eller Ctrl/Cmd nede, mens du trækker i pilene ved ekstrudering eller flytning, for at undgå faste værditrin. Slået fra: Kun Ctrl/Cmd.", "ui.manufacture": "Fremstilling", @@ -2164,11 +4354,18 @@ "ui.mesh_format_3mf": "3MF", "ui.mesh_format_3mf_tagged": "3MF med flademærker", "ui.mesh_format_stl": "STL", + "ui.minor_radius": "Mindre radius", "ui.mirror": "Spejling", "ui.mirror_line": "Spejlingslinje", + "ui.modifiers_either": "{first} eller {second}", "ui.move": "Flyt", "ui.multiple": "Flere", "ui.named_views": "Navngivne visninger", + "ui.nav_bridge": "Naviger med en enhedsapp på denne computer", + "ui.nav_bridge_connected": "Forbundet", + "ui.nav_bridge_port": "Port", + "ui.nav_bridge_tip": "En 3D-enheds eget program (for eksempel driveren til en hjemmebygget 3D-mus) flytter visningen over en lokal forbindelse, så musemarkøren forbliver din. SolidCraft opretter forbindelse til den på denne port, kun på denne computer.", + "ui.nav_bridge_waiting": "Venter på enhedsappen", "ui.navigation": "Navigation", "ui.needs_design": "{command}: Åbn først et design", "ui.newSketchGroup": "Ny skitsegruppe", @@ -2187,6 +4384,7 @@ "ui.one_side": "Én side", "ui.open": "Åbn", "ui.open_ends_tip": "Berører intet", + "ui.open_linked_design": "Åbn linket design", "ui.open_preferences": "Åbn indstillinger", "ui.open_recent": "Åbn seneste", "ui.operation": "Operation", @@ -2207,6 +4405,11 @@ "ui.paste": "Indsæt", "ui.path": "Bane", "ui.pattern_on_path": "Mønster langs bane", + "ui.perforate_margin_default": "ud fra diameteren", + "ui.perforate_pitch": "Deling", + "ui.perforate_square": "Kvadratisk", + "ui.perforate_staggered": "Forskudt (60°)", + "ui.perforate_title": "PERFORÉR", "ui.perpendicular": "Vinkelret", "ui.perspective_view": "Perspektivvisning", "ui.pinToShortcuts": "Fastgør til genveje", @@ -2214,26 +4417,61 @@ "ui.pipe": "Rør", "ui.pitch": "Stigning", "ui.placement": "Placering", + "ui.placement_handles": "Placeringshåndtag", + "ui.placement_handles_tip": "Håndtag på forhåndsvisningen af en Understøtning, Skruebøsning eller Snapforbindelse: Træk i markøren for at flytte den hen over modellen, og drej den ved dens ring", "ui.plane": "Plan", "ui.plane_at_angle": "Plan i vinkel", "ui.plugins": "Plugins", "ui.plugins_desktop": "Plugins er tilgængelige i desktopappen.", "ui.plugins_empty": "Ingen plugins installeret.", + "ui.plugins_none": "Ingen plugins matcher denne søgning.", "ui.plugins_note": "Hvert plugin ligger i sin egen mappe under {path} og indeholder en plugin.json-fil. SolidCraft læser filen uden at køre kode fra mappen. Et plugin forbliver installeret, når du slår det fra.", + "ui.plugins_search": "Søg i plugins", "ui.plugins_skipped": "Kunne ikke læse {count} mapper.", "ui.plugins_version": "{version}", "ui.point": "Punkt", + "ui.point_cloud_add_filter": "Tilføj filter…", + "ui.point_cloud_auto_range": "Automatisk område", + "ui.point_cloud_color_by": "Farvelæg efter", + "ui.point_cloud_file_colors": "Filens farver", + "ui.point_cloud_filter_by": "Filtrér efter", + "ui.point_cloud_height_high": "Højeste", + "ui.point_cloud_height_low": "Laveste", + "ui.point_cloud_legend_filter": "Vist og skjult i signaturforklaringen ovenfor", + "ui.point_cloud_no_file_colors": "Filen har ingen farver", + "ui.point_cloud_pause": "Pause", + "ui.point_cloud_play": "Afspil", + "ui.point_cloud_single_color": "Én farve", + "ui.point_cloud_size": "Punktstørrelse", + "ui.point_cloud_units": "Filens enheder", "ui.point_under_the_cursor": "Punkt under markøren", "ui.points": "Punkter", "ui.polygon": "Polygon", "ui.preferences": "Indstillinger", "ui.press_a_key": "tryk på en tast…", + "ui.print_face_cleared": "{body}: printflade ryddet", + "ui.print_face_input": "Printflade", + "ui.print_face_one_body": "En printflade hører til ét legeme: send det legeme alene for at ændre den", + "ui.print_face_pick": "Vælg en plan flade på et massivt legeme for at gøre den til printfladen", + "ui.print_face_set": "{body}: printflade sat; Send til slicer lægger legemet på den", "ui.profile": "Profil", "ui.profile_plane": "Profilplan", "ui.profiles": "Profiler", "ui.project": "Projektér", "ui.project_face_edges_sketch": "Projektér fladekanter til nye skitser", "ui.properties": "Egenskaber", + "ui.properties.copy_matrix": "Kopiér matrix", + "ui.properties.first_moments": "Første momenter", + "ui.properties.inertia": "Inertimoment", + "ui.properties.inertia_note": "Om massecentret, i verdensakser (g·{unit}²)", + "ui.properties.inertia_unavailable": "Ikke tilgængeligt: Et emnes mesh er ikke lukket.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Måler…", + "ui.properties.measuring_failed": "Målingen blev stoppet, før den var færdig.", + "ui.properties.principal_axis": "Hovedakse {number}", + "ui.properties.principal_moments": "Hovedmomenter", "ui.pull_direction": "Udtræksretning", "ui.quality": "Kvalitet", "ui.quantity": "Antal", @@ -2260,6 +4498,7 @@ "ui.render": "Rendering", "ui.renderer": "Renderingsmotor", "ui.repeat": "Gentag", + "ui.repeat_nothing": "Intet at gentage endnu: Start først et værktøj eller en dialog.", "ui.replace_with_link_done": "Kopierne er nu placeringer af det linkede design.", "ui.report_a_bug": "Rapportér en fejl", "ui.reset": "Nulstil", @@ -2269,15 +4508,22 @@ "ui.restore_defaults": "Gendan standarder", "ui.reverse_horizontal_pan_direction": "Vend vandret panoreringsretning", "ui.reverse_horizontal_pan_tip": "Vandret rulning med to fingre og tommelfingerhjulet panorerer i den modsatte retning", + "ui.reverse_trackpad_orbit_direction": "Vend retningen for rotation med pegefeltet", + "ui.reverse_trackpad_orbit_tip": "Shift med rulning med to fingre roterer visningen den anden vej", + "ui.reverse_vertical_pan_direction": "Vend retningen for lodret panorering", + "ui.reverse_vertical_pan_tip": "Lodret rulning med to fingre panorerer den anden vej", "ui.reverse_zoom_direction": "Vend zoomretning", + "ui.revolutions": "Omdrejninger", "ui.revolve": "Omdrejning", "ui.ribbon_exit": "Afslut", "ui.ribbon_labels": "Vis tekst under ikonerne på båndet", + "ui.ribbon_menus_skip_pinned": "Udelad fastgjorte kommandoer i rullemenuerne", "ui.ribbon_pin_footer": "Fastgjorte kommandoer vises på båndet. Hold musen over en række for at fastgøre eller frigøre den.", "ui.ribbon_workspace": "Arbejdsområde", "ui.right": "Fra højre", "ui.save": "Gem", "ui.save_as": "Gem som", + "ui.save_mesh_no_slicer_open": "Ingen slicer fundet: Vælg Brugerdefineret… og vælg dit slicerprogram", "ui.save_mesh_pick_bodies": "Vælg de emner, der skal gemmes", "ui.saved_to": "Gemt: {path}", "ui.sbox_pin": "Fastgør øverst i S-menuen", @@ -2285,15 +4531,19 @@ "ui.scale": "Skalering", "ui.second_rail": "Anden styrekurve", "ui.section_analysis": "Snitanalyse", + "ui.section_size": "Snittets størrelse", "ui.select": "Vælg", "ui.selectOther": "Vælg andet…", "ui.selectTangentChain": "Vælg tangentkæde", + "ui.select_all": "Vælg alle", "ui.selection": "Markering", "ui.selection_modifiers": "Taster ved markering", "ui.selection_shift_add_only": "Shift+klik tilføjer uden at fjerne", "ui.selection_shift_add_only_tip": "Slået til: Shift tilføjer til markeringen. Ctrl/Cmd tilføjer eller fjerner. Slået fra (standard): Både Shift og Ctrl/Cmd tilføjer eller fjerner.", "ui.send": "Send", + "ui.send_to_slicer_unavailable": "Send til slicer kræver desktopappen: En browser kan ikke starte et program", "ui.sent_to_slicer": "Sendt til {slicer}", + "ui.sent_to_slicer_copy": "Sendt til {slicer} som {file}, fordi den første fil stadig er åben der", "ui.sheet_gap": "Mellemrum", "ui.shell": "Udhulning", "ui.shortcut_capture_add_key": "Tilføj én almindelig tast. Escape annullerer.", @@ -2301,6 +4551,8 @@ "ui.shortcut_capture_press": "Tryk på modifikationstaster og én tast. Escape annullerer.", "ui.shortcut_capture_release": "Slip alle taster for at bekræfte. Escape annullerer.", "ui.shortcut_capture_settling": "Bekræfter genvej… Escape annullerer.", + "ui.shortcut_cycles_modes": "Hvis du trykker på et værktøjs genvej igen, skifter det tilstand", + "ui.shortcut_cycles_modes_tip": "Før det første punkt: Hvis du trykker på et skitseværktøjs genvej igen, gennemgås dets oprettelsestilstande. Fra: Værktøjet starter igen.", "ui.shortcut_multiple_keys": "Brug modifikationstaster og kun én anden tast. Klik på tastetildelingen for at prøve igen.", "ui.show": "Vis", "ui.showAll": "Vis alle", @@ -2320,12 +4572,30 @@ "ui.sketch": "Skitse", "ui.sketch_dimension": "Skitsemål", "ui.sketch_grid": "Skitsegitter", + "ui.sketch_intersect_geometry": "Geometri", + "ui.sketch_intersect_projection_link": "Projektionslink", + "ui.sketch_intersect_selection_filter": "Markeringsfilter", "ui.sketch_modifiers": "Taster i skitser", + "ui.sketch_project_geometry": "Geometri", + "ui.sketch_report.all_constrained": "Alt er fuldt bundet.", + "ui.sketch_report.dof": "Frihedsgrader", + "ui.sketch_report.loose": "Ikke fuldt bundet", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Skitserapport", "ui.sketch_scale": "Skitseskalering", "ui.sketches": "Skitser", "ui.slice": "Lagdeling", "ui.slicer": "Slicer", "ui.slicer_delete_tip": "{program}\nTryk Delete for at fjerne det fra listen", + "ui.slicer_lost": "{slicer} blev startet, men kan ikke findes igen for at sende filen til den. Filen er gemt i {path}: Åbn den derfra.", + "ui.slicer_no_file": "{slicer} er klar, men der er ingen fil at sende til den.", + "ui.slicer_no_response": "{slicer} svarede ikke i tide. Filen er allerede gemt i {path}: Send den igen, når slicerprogrammet er startet.", + "ui.slicer_resend_hint": "Hvis du sender igen, erstattes den fil, som slicerprogrammet har åben.", + "ui.slicer_single_instance": "Åbn filer i en slicer, der allerede kører", + "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer og Cura indlæser filen i det vindue, der er åbent, i stedet for at starte et nyt. Andre slicere startes, som de er. PrusaSlicer 3.0 gør altid dette: Dens starter kan ikke tage en fil på nogen anden måde.", + "ui.slicer_starting": "Starter {slicer}…", + "ui.slicer_still_starting": "Venter stadig på, at {slicer} starter. Intet andet blev sendt.", + "ui.slicer_stl_fallback": "3MF-filen kunne ikke skrives, så dette er en STL.", "ui.slot": "Slids", "ui.smooth": "Glat", "ui.smooth_wheel_zoom": "Jævn zoom med musehjul", @@ -2341,6 +4611,9 @@ "ui.start": "Start", "ui.start_over": "Start forfra", "ui.start_page": "Startside", + "ui.step_values": "Trin i værdier med piletasterne og hjulet", + "ui.step_values_tip": "I et værdifelt, du skriver i, ændrer Op/Ned et almindeligt tal med 1 (Shift: 10, Alt: 0,1), og det gør musehjulet over feltet også. Udtryk bevares, som de er skrevet.", + "ui.stitch_tolerance_hint": "stram", "ui.stretch": "Stræk", "ui.supersampling": "Supersampling (SSAA)", "ui.supersampling_adaptive": "Kun når visningen står stille", @@ -2359,6 +4632,7 @@ "ui.tab_manage": "Administrér", "ui.tab_sheet_metal": "Plade", "ui.tab_view": "Visning", + "ui.tab_weldments": "Svejsning", "ui.tangent": "Tangent", "ui.taper_angle": "Konusvinkel", "ui.tapped": "Med gevind", @@ -2374,11 +4648,15 @@ "ui.tip_angle": "Spidsvinkel", "ui.to_object": "Til objekt", "ui.tool_direction": "Værktøjsretning", + "ui.toolbar_tabs_hidden": "Alle værktøjslinjefaner er skjult. Klik på + eller højreklik her for at vise en.", "ui.top": "Ovenfra", + "ui.top_diameter": "Topdiameter", + "ui.top_radius": "Topradius", "ui.torus": "Torus", "ui.total_angle": "Samlet vinkel", "ui.trackpad_pan_tip": "Rulning med to fingre panorerer; i Fusion roterer Shift + to fingre visningen. Fra: Rulning zoomer. Musehjul og knibebevægelser zoomer; tommelfingerhjulet panorerer sidelæns.", "ui.trim": "Trim", + "ui.turn_up_axis": "Drej opadgående akse", "ui.twist": "Vridning", "ui.two_finger_scroll_pans": "Rulning med to fingre panorerer", "ui.two_sides": "To sider", @@ -2395,12 +4673,39 @@ "ui.up_axis_note": "Hvilken akse der peger opad i visningerne (aksevisning, Hjem, Se på). Designs ændres ikke.", "ui.updateToCurrentView": "Opdatér til aktuel visning", "ui.upload": "Upload", + "ui.vertical_constraint": "Lodret binding", + "ui.view_angles": "Visningsvinkler", + "ui.view_angles_convention": "Grader om den opadgående akse. Krøjning 0° er visningen Forfra, 90° Fra venstre, −90° Fra højre, 180° Bagfra; stampning 90° er visningen Ovenfra; en positiv rulning drejer modellen med uret.", + "ui.view_angles_help": "Viser visningens stampning, krøjning og rulning i grader samt dens projektion under orienteringsgizmoen. Klik på visningen for at skrive eksakte vinkler.", + "ui.view_angles_ortho": "Orto", + "ui.view_angles_persp": "Persp", + "ui.view_angles_pitch": "Stampning", + "ui.view_angles_readout": "Stampning {pitch} · Krøjning {yaw} · Rulning {roll}", + "ui.view_angles_roll": "Rulning", + "ui.view_angles_yaw": "Krøjning", "ui.view_centre": "Visningens centrum", + "ui.viewport_status_clear": "Slå {name} fra", + "ui.viewport_status_clear_hint": "Slå {name} fra · Esc", + "ui.viewport_status_edges": "{count} kanter", + "ui.viewport_status_from": "fra {direction}", + "ui.viewport_status_key": "Vis signaturforklaring for {name}", + "ui.viewport_status_minimum_radius": "Mindste radius", + "ui.viewport_status_pairs": "{count} par", + "ui.viewport_status_section_hatch": "{setting} · skravering", + "ui.viewport_status_section_plane": "Aktuelt plan", + "ui.viewport_status_section_tilted": "vippet · {offset}", + "ui.viewport_status_stripes": "{count} striber", + "ui.viewport_theme_follow": "Følg tema", "ui.visual_shaded_hidden_edges": "Skyggelagt med skjulte kanter", "ui.visual_shaded_with_edges": "Skyggelagt med kanter", "ui.visual_style": "Visuel stil", "ui.visual_wireframe": "Trådmodel", + "ui.wheel_zoom_speed": "Zoomhastighed med musehjul", "ui.width": "Bredde", + "ui.window_system": "Vinduessystem", + "ui.window_system_auto": "Automatisk", + "ui.window_system_wayland": "Wayland", + "ui.window_system_x11": "X11", "ui.workspace": "arbejdsområde", "ui.workspace_animation_description": "Eksploderede visninger, bevægelsesstudier og storyboards", "ui.workspace_available": "Tilgængelig", @@ -2426,7 +4731,9 @@ "update.button": "Opdatér", "update.button_version": "Opdatér til {version}", "update.check_automatically": "Søg automatisk efter opdateringer", + "update.check_for_updates": "Søg efter opdateringer", "update.check_now": "Søg nu", + "update.check_unavailable": "Søgning efter opdateringer er ikke tilgængelig for denne installation.", "update.checking": "Søger…", "update.copy": "Kopiér", "update.dmg_opened": "Diskbilledet er åbnet og verificeret: Træk SolidCraft til Programmer, erstat den gamle kopi, og genstart SolidCraft.", @@ -2440,12 +4747,18 @@ "update.installed": "SolidCraft {version} er installeret. Genstart for at bruge den.", "update.installer_started": "Installationsprogrammet starter, når SolidCraft lukker.", "update.later": "Påmind mig senere", + "update.managed": "Opdatering tilgængelig: Opdatér SolidCraft i {manager}", "update.no_notes": "Der er ingen beskrivelse af denne version.", "update.package_hint": "Denne kopi kom fra en {kind}. Download den nye pakke, og installér den med:", "update.prefs_dev": "Dette er en udviklingsudgave ({version}): Den søger ikke efter opdateringer.", "update.prefs_flatpak": "Flatpak opdaterer denne kopi ({version}): Kør {command}, eller brug dit softwarecenter. SolidCraft søger ikke efter opdateringer.", "update.prefs_label": "Opdateringer", + "update.prefs_managed": "Dette er SolidCraft {version}, administreret af {manager}. En kontrol spørger GitHub om den seneste udgivelse og sender intet andet; opdatér SolidCraft i {manager}.", "update.prefs_note": "Dette er SolidCraft {version} ({kind}). En opdateringskontrol spørger GitHub om den seneste udgivelse og sender intet andet.", + "update.prefs_web": "Genindlæs siden for at få den nyeste webapp. Hvis netværket ikke er tilgængeligt, eller opdateringen ikke kan nå at blive downloadet i tide, åbnes den gemte version i stedet; en igangværende download fortsætter til næste genindlæsning.", + "update.refresh_checking": "Søger efter den nyeste udgivelse…", + "update.refresh_fallback": "Kunne ikke søge efter en nyere udgivelse; opdaterer til {version}, som blev fundet tidligere.", + "update.refresh_selected": "Opdaterer til {version}.", "update.release_page": "Åbn udgivelsesside", "update.restart": "Genstart nu", "update.restart_later": "Senere", @@ -2456,6 +4769,17 @@ "update.up_to_date": "SolidCraft er opdateret.", "update.update_now": "Opdatér nu", "update.you_have": "Du har {version}.", + "use a positive nominal diameter and a pitch smaller than it": "brug en positiv nominel diameter og en stigning, der er mindre end den", + "values must be finite numbers": "værdierne skal være endelige tal", + "values must be numbers or expressions": "værdierne skal være tal eller udtryk", + "view.auto_ortho_faces": "Perspektiv under rotation, ortografisk på flader", + "view.render_inspector": "Renderingsinspektør", + "web.files.insert_drawings_hint": "CAD-filer, meshes, DXF eller SVG ind i det åbne design", + "web.files.insert_stored_drawings_hint": "Ind i det åbne design (CAD-filer, meshes, DXF, SVG)", + "web.open_full": "Åbn i SolidCraft", + "web.open_link": "Åbn linket design?", + "web.open_link_confirm": "Der er ændringer, som ikke er gemt. Skal det linkede design åbnes i en ny fane?", "↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ vælg · Enter / Tab udfyld · Esc luk", - "↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ vælg · Enter anvender det indtastede · Esc lukker" + "↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ vælg · Enter anvender det indtastede · Esc lukker", + "⌘Enter continues on GitHub.": "⌘Enter fortsætter på GitHub." } diff --git a/assets/locales/de.json b/assets/locales/de.json index 586d969111..731e1dd5f7 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -60,8 +60,10 @@ "BLEND SURFACE": "ÜBERGANGSFLÄCHE", "BOUNDARY SURFACE": "BERANDUNGSFLÄCHE", "BROWSER": "BROWSER", + "Back to the original order": "Zurück zur ursprünglichen Reihenfolge", "Ball": "Kugelgelenk", "Bar": "Flachstab", + "Base Center": "Mittelpunkt der Basis", "Base Height": "Sockelhöhe", "Bend": "Biegung", "Bend Edges": "Biegekanten", @@ -205,6 +207,7 @@ "Edges": "Kanten", "Edit": "Bearbeiten", "Edit Profile Sketch": "Profile-Skizze bearbeiten", + "Edit shortcuts": "Tastenkürzel bearbeiten", "Enable rule": "Regel aktivieren", "Enabled": "Aktiviert", "End Radius": "Endradius", @@ -259,6 +262,8 @@ "Flanges": "Flansche", "Flat Pattern": "Flachmuster", "Flat size": "Abwicklungsgröße", + "Flat size X": "Abwicklungsmaß X", + "Flat size Y": "Abwicklungsmaß Y", "Follow camera (Look At sketches)": "Kamera folgen (Skizzen ansehen)", "Follow the camera (Look At sketches)": "Der Kamera folgen (Skizzen ansehen)", "Font": "Schriftart", @@ -290,6 +295,7 @@ "Horiz./Vert.": "Horiz./Vert.", "I-Beam": "I-Träger", "IMAGES": "BILDER", + "If you are offline or GitHub does not open, use Copy report and keep the text to submit later.": "Wenn Sie offline sind oder GitHub nicht geöffnet werden kann, verwenden Sie „Bericht kopieren“ und bewahren Sie den Text auf, um ihn später einzureichen.", "Import material into design": "Material ins Design importieren", "Import…": "Importieren…", "In This Design": "In diesem Design", @@ -400,13 +406,16 @@ "Of": "Freiheitsgrad", "On your computer": "Auf diesem Computer", "Opacity": "Deckkraft", + "Open GitHub": "GitHub öffnen", "Open GitHub draft": "GitHub-Entwurf öffnen", + "Open GitHub issue form": "GitHub-Issueformular öffnen", "Open bodies (no volume)": "Offene Körper (ohne Volumen)", "Open library...": "Bibliothek öffnen...", "Open local libraries in the desktop app. You can export this design's library here.": "Öffnen Sie lokale Bibliotheken in der Desktop-App. Die Bibliothek dieses Designs können Sie hier exportieren.", "Open surface — no volume": "Offene Oberfläche – kein Volumen", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Quelloffenes parametrisches 3D-CAD in Rust. Lizenziert unter MIT OR Apache-2.0.", "Opens your browser and copies the report text for you to paste into GitHub.": "Öffnet den Browser und kopiert den Berichtstext, den Sie anschließend auf GitHub einfügen können.", + "Opens your browser and copies the report text. Review and submit your report on GitHub.": "Öffnet den Browser und kopiert den Berichtstext. Prüfen Sie den Bericht und reichen Sie ihn anschließend auf GitHub ein.", "Opens your browser. Review and submit your report on GitHub.": "Öffnet den Browser. Prüfen Sie den Bericht und reichen Sie ihn auf GitHub ein.", "Operator": "Operator", "Orbit (right drag)": "Drehen (mit rechts ziehen)", @@ -461,6 +470,7 @@ "Radius": "Radius", "Radius Fit": "Radiusanpassung", "Ratio": "Verhältnis", + "Read-only list: shortcuts can't be changed here.": "Schreibgeschützte Liste: Tastenkürzel können hier nicht geändert werden.", "Reattach selected face": "Ausgewählte Fläche neu anbinden", "Recover": "Wiederherstellen", "Recover unsaved design?": "Nicht gespeichertes Design wiederherstellen?", @@ -487,6 +497,7 @@ "Reopen the designs that were open when SolidCraft was last closed": "Die Designs erneut öffnen, die beim letzten Beenden von SolidCraft geöffnet waren", "Replace existing library file": "Vorhandene Bibliotheksdatei ersetzen", "Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen", + "Report copied. Keep this text to submit on GitHub when you are online.": "Bericht kopiert. Bewahren Sie diesen Text auf, um ihn auf GitHub einzureichen, sobald Sie online sind.", "Report text copied. Paste it into GitHub and submit your report there.": "Berichtstext kopiert. Fügen Sie ihn auf GitHub ein und reichen Sie den Bericht dort ein.", "Reports are public. A GitHub account is required.": "Berichte sind öffentlich. Ein GitHub-Konto ist erforderlich.", "Reset Toolbar": "Symbolleiste zurücksetzen", @@ -583,7 +594,10 @@ "Slider": "Schubgelenk", "Slot width": "Langlochbreite", "Snap to face and edge centres": "An Flächen- und Kantenmittelpunkten einrasten", + "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft kann Berichte nicht direkt einreichen. Reichen Sie Ihren Bericht auf GitHub ein. In der App wird dafür kein Token benötigt.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft wurde beendet, bevor diese Designs gespeichert wurden. Ihr letzter Autosave kann geöffnet werden.", + "Sort A to Z": "Von A bis Z sortieren", + "Sort Z to A": "Von Z bis A sortieren", "Spacing 2 must be a non-zero number": "Abstand 2 muss eine Zahl ungleich null sein", "Spacing must be a non-zero number": "Der Abstand muss eine Zahl ungleich null sein", "Specialise": "Spezialisieren", @@ -643,6 +657,7 @@ "Third point": "Dritter Punkt", "This child is shared. Deleting it removes it from every copy of its parent.": "Dieses untergeordnete Element ist geteilt. Löschen entfernt es aus jeder Kopie des übergeordneten Elements.", "This child is shared. Position changes apply to every copy of its parent.": "Dieses untergeordnete Element ist geteilt. Positionsänderungen gelten für jede Kopie des übergeordneten Elements.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Diese Komponente gehört zu einer Unterbaugruppe, die mehrfach platziert wurde. Beim Ausrichten wird sie an allen Platzierungen verschoben.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Diese Fläche kann keine dauerhafte Anbindung bieten. Wählen Sie eine andere Fläche oder verwenden Sie ausdrücklich eine feste Ebene.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Dieses Bild wurde noch nicht an GitHub angehängt. Kopieren oder speichern Sie es und fügen Sie es anschließend Ihrem GitHub-Bericht hinzu.", "Thread depth": "Gewindetiefe", @@ -763,6 +778,8 @@ "at the face": "an der Fläche", "bom.group_children": "Unterteile in der Zeile ihres Teils", "browser.body_name": "Körper {number}", + "browser.calibrate_click_a": "Klicken Sie im Bild auf die Position von Punkt A oder geben Sie dessen Koordinaten ein.", + "browser.calibrate_click_b": "Klicken Sie im Bild auf die Position von Punkt B oder geben Sie dessen Koordinaten ein.", "browser.calibrate_title": "Kalibrieren: {name}", "browser.capture_many": "{count} Komponenten wurden verschoben, ihre Positionen sind aber nicht erfasst.", "browser.capture_note": "„Position erfassen“ behält die neue Position bei; „Position zurücksetzen“ stellt die vorherige wieder her.", @@ -787,6 +804,8 @@ "camera.shot_pitch": "Nickwinkel", "camera.shot_roll": "Rollwinkel", "camera.shot_yaw": "Gierwinkel", + "canvas.decal_place_hint": "Klicken Sie auf eine Fläche, um das Dekal zu platzieren.", + "canvas.place_hint": "Klicken Sie auf eine Ebene oder Fläche, um die Zeichenfläche zu platzieren.", "choose a profile": "ein Profil wählen", "choose the cam's and the follower's joints": "die Gelenke von Nocken und Stößel wählen", "choose the joints to couple": "die zu koppelnden Gelenke wählen", @@ -803,6 +822,7 @@ "cmd.appearance_pick_up": "Aussehen aufnehmen", "cmd.appearance_replace": "Erscheinungsbild ersetzen", "cmd.assembly_bom": "Stückliste", + "cmd.body.print_face": "Auflagefläche", "cmd.body_rename": "Körper umbenennen", "cmd.browser_collapse_all": "Alles einklappen", "cmd.browser_expand_all": "Alles ausklappen", @@ -839,6 +859,7 @@ "cmd.component_move_bodies": "Körper in Komponente verschieben", "cmd.component_move_features": "Merkmale in Komponente verschieben", "cmd.component_move_sketches": "Skizzen in Komponente verschieben", + "cmd.component_paste_from_design": "Aus einem anderen Design einfügen", "cmd.component_paste_new": "Neu einfügen", "cmd.component_properties": "Physikalische Eigenschaften der Komponente", "cmd.component_relink": "Quelle neu verknüpfen", @@ -975,6 +996,7 @@ "cmd.motion_trace": "Bewegungsbahn", "cmd.motion_trace_edit": "Bewegungsbahn bearbeiten", "cmd.motion_trace_keep": "Bewegungsbahn behalten", + "cmd.occurrence_align": "Instanz ausrichten", "cmd.occurrence_copy": "Einfügen (Instanz)", "cmd.occurrence_drag": "Instanz ziehen", "cmd.occurrence_ground": "Festsetzen", @@ -1085,6 +1107,16 @@ "cmd.sheet_refold": "Wieder falten", "cmd.sheet_rules": "Blechregeln auflisten", "cmd.sheet_unfold": "Entfalten", + "cmd.sketch.construction_line": "Konstruktionslinie", + "cmd.sketch.construction_line.angle": "Konstruktionslinie unter Winkel", + "cmd.sketch.construction_line.bisector": "Winkelhalbierende", + "cmd.sketch.construction_line.horizontal": "Horizontale Konstruktionslinie", + "cmd.sketch.construction_line.parallel": "Parallele Konstruktionslinie", + "cmd.sketch.construction_line.perpendicular": "Senkrechte Konstruktionslinie", + "cmd.sketch.construction_line.tangent_at": "Tangente an Kurvenpunkt", + "cmd.sketch.construction_line.tangent_external": "Tangente durch äußeren Punkt", + "cmd.sketch.construction_line.tangent_two": "Gemeinsame Tangente", + "cmd.sketch.construction_line.vertical": "Vertikale Konstruktionslinie", "cmd.sketch_arc3d": "3D-Bogen", "cmd.sketch_arc_center_point": "Mittelpunkt-Kreisbogen", "cmd.sketch_arc_tangent": "Tangentialbogen", @@ -1300,6 +1332,7 @@ "dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen", "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.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.", @@ -1356,31 +1389,59 @@ "from the profile": "aus dem Profil", "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.attributions": "Quellenangaben (ATTRIBUTION.md)", "help.build": "Version {version} · Commit {commit} · erstellt am {date}", "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.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.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.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.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.mit": "MIT-Lizenz", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Erstellt eine unendliche horizontale Konstruktionslinie durch einen Punkt. Die Horizontalbedingung bleibt bei späteren Änderungen erhalten.", + "help.sketch.construction_line.parallel": "Gerade Skizzenlinie wählen, dann eine parallele unendliche Konstruktionslinie platzieren. Rechtsklick wählt eine andere Referenz.", + "help.sketch.construction_line.perpendicular": "Gerade Skizzenlinie wählen, dann eine senkrechte unendliche Konstruktionslinie platzieren. Rechtsklick wählt eine andere Referenz.", + "help.sketch.construction_line.tangent_at": "Erstellt eine Tangente an einem Punkt eines Kreises oder Kreisbogens oder an einem Endpunkt eines offenen Splines oder Kegelschnitts. Kurve wählen, dann Berührpunkt.", + "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.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.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.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.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.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.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.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.", "help.solid.loft": "Erzeugt einen glatten Übergangskörper zwischen zwei oder mehr Skizzenprofilen oder von einem Profil zu einem Punkt. Für Übergänge, Trichter, Griffe und alle Formen, deren Querschnitt sich über die Länge ändert.", "help.solid.mirror": "Erzeugt eine gespiegelte Kopie der gewählten Körper oder Features an einer Ebene und kann sie mit dem Original vereinigen. Für symmetrische Teile: Sie modellieren eine Hälfte und erhalten beide.", + "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.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.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.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.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.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.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.", "history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)", "history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)", @@ -1484,6 +1545,21 @@ "joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.", "joint.rigid_group_one_component": "Diese {count} Körper liegen alle in einer Komponente (oder direkt im Design): Verwenden Sie zuerst {command}.", "joint.rigid_group_select_components": "Wählen Sie zwei oder mehr Komponenten aus.", + "joint_move.blocked_hint": "Prüfen Sie den Gelenktyp (z. B. „Starr“), die Fixierung, die Gelenkgrenzen und die durch Parameter gesteuerten Werte.", + "joint_move.blocked_title": "Dieses Bauteil kann nicht bewegt werden", + "joint_move.coarse": "Drehschritt nahe der Achse (°)", + "joint_move.distance": "Abstand vom Ausgangspunkt", + "joint_move.done": "Fertig", + "joint_move.fine": "Drehschritt weit von der Achse entfernt (°)", + "joint_move.fixed": "Dieses Bauteil verfügt über keine bewegliche Gelenkachse.", + "joint_move.help": "Verschieben: Ziehen Sie einen Pfeil oder Ring. Mit Esc brechen Sie den aktuellen Ziehvorgang ab.", + "joint_move.legacy_hint": "Deaktivieren, um wieder den bisherigen Verschiebebereich zu verwenden.", + "joint_move.precision": "Ziehen Sie außerhalb der gestrichelten Führung, um die Drehung fein einzustellen.", + "joint_move.rotation": "Drehwinkel vom Ausgangspunkt", + "joint_move.select": "Wählen Sie eine Fläche oder eine Baugruppenkomponente aus und anschließend „Verschieben“.", + "joint_move.settings": "Baugruppe verschieben", + "joint_move.step": "Schrittweite", + "joint_move.use_handles": "Gelenkgriffe zum Bewegen von Baugruppen verwenden", "keymap.command_id": "Befehls-ID", "keymap.empty": "Keine passenden Befehle", "keymap.use_anyway": "{key} hier trotzdem verwenden", @@ -1543,6 +1619,10 @@ "material.selection.mixed": "Unterschiedliche Materialien", "measure.at": "Bei", "measure.item": "Element {number}", + "measure.quick_copied": "Messwert kopiert", + "measure.quick_copy": "Klicken, um den Messwert zu kopieren", + "measure.quick_edges": "{count} Kanten", + "measure.quick_faces": "{count} Flächen", "messages.ago_h": "vor {n} Std.", "messages.ago_min": "vor {n} Min.", "messages.ago_s": "vor {n} Sek.", @@ -1695,6 +1775,11 @@ "not a move": "keine Verschiebung", "not a number": "keine Zahl", "open a sketch first": "zuerst eine Skizze öffnen", + "parameters.apply_live": "Jede Änderung beim Verlassen des Feldes übernehmen", + "parameters.apply_live_tip": "Aus: Änderungen warten auf Übernehmen oder OK, dann wird das Modell einmal für alle neu aufgebaut. Abbrechen oder Esc verwirft sie.", + "parameters.held": "{count} Änderungen warten auf Übernehmen", + "parameters.held_hint": "Bearbeiten Sie Ausdrücke und Einheiten. Änderungen warten auf Übernehmen oder OK.", + "parameters.held_one": "1 Änderung wartet auf Übernehmen", "parts.automatic_name": "automatisch", "parts.dimension.across_flats": "Schlüsselweite", "parts.dimension.bore": "Bohrung", @@ -1717,6 +1802,8 @@ "parts.family.set_screw": "Gewindestift mit Innensechskant und Kegelstumpf", "parts.family.socket_head_cap_screw": "Zylinderschraube mit Innensechskant", "parts.family.washer": "Unterlegscheibe", + "parts.holes": "Bohrungen (optional)", + "parts.placement_hint": "Wählen Sie eine oder mehrere Bohrungen, um in jede ein Teil einzusetzen, oder OK platziert es im Ursprung", "pattern.alignment_2": "Ausrichtung 2", "pattern.alignment_hint": "Die Anzahl enthält das Original. Die zentrierte Anordnung verteilt Kopien auf beide Seiten; bei gerader Anzahl legt die Richtungsumkehr die Seite mit der zusätzlichen Kopie fest.", "pattern.direction_1": "Richtung 1", @@ -1757,6 +1844,8 @@ "prefs.anti_aliasing_note": "Glättet gezackte Kanten.", "prefs.applies_on_restart": "Wird nach einem Neustart übernommen", "prefs.assistant": "Assistent", + "prefs.auto_operation": "Operation automatisch auswählen", + "prefs.auto_operation_note": "Die Feature-Dialoge wählen automatisch zwischen „Verbinden“, „Neuer Körper“ und „Ausschneiden“, abhängig davon, was das neue Material berührt. Sobald Sie selbst eine Operation auswählen, wird diese Einstellung nicht mehr angewendet. Deaktiviert: Die beim Öffnen des Dialogs voreingestellte Operation wird beibehalten.", "prefs.axes": "Achsen", "prefs.axis": "Achse", "prefs.bodies_folders": "Körperordner", @@ -1767,6 +1856,8 @@ "prefs.bottom_centre": "Unten mittig", "prefs.bottom_right": "Unten rechts", "prefs.browser": "Browser", + "prefs.browser_eye_left": "Auge neben dem Aufklapppfeil", + "prefs.browser_eye_left_note": "Browserzeilen zeigen ihr Auge zum Ein- und Ausblenden wie bisher neben dem Aufklapppfeil statt in einer Spalte am rechten Zeilenende.", "prefs.browser_reveal_on_hover": "Beim Überfahren einblenden", "prefs.browser_show_note": "Angezeigt lässt den Browser auf dem Bildschirm. Beim Überfahren einblenden blendet ihn aus, bis der Zeiger über seinen Platz oder die schmale Lasche an seinem linken Rand kommt; er bleibt offen, solange der Zeiger darin ist oder ein Menü daraus offen ist.", "prefs.browser_shown": "Angezeigt", @@ -1817,6 +1908,8 @@ "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_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", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Bindungssymbole verschwinden, wenn die Skizze so weit herausgezoomt ist, dass sie sie überladen würden. Aus: sie behalten bei jedem Zoom eine feste Bildschirmgröße.", "prefs.history": "Verlauf", "prefs.history_origin_bodies": "Ursprung und Körper oben zeigen", "prefs.history_origin_bodies_note": "Der Verlauf links oder unter dem Browser beginnt mit dem Körper-Ordner der aktiven Komponente und dem Ursprung, mit Augen und Menüs wie im Browser. Der Verlauf unten bleibt unverändert.", @@ -1843,6 +1936,11 @@ "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.linked_designs": "Verknüpfte Designs", + "prefs.live_input": "Live-Eingabe", + "prefs.live_preview": "Live-Vorschau", + "prefs.logo": "Logo", + "prefs.logo_accent": "Akzentfarbe", + "prefs.logo_theme": "Designfarben", "prefs.max_file_size": "Maximale Dateigröße zum Öffnen", "prefs.max_file_size_design": "Designdateien", "prefs.max_file_size_flag": "Dieser Lauf wurde mit --max-file-size {size} gestartet. Der Wert gilt, bis SolidCraft geschlossen wird. Diese Einstellungen gelten beim Start ohne diese Option.", @@ -1850,10 +1948,36 @@ "prefs.max_file_size_step": "STEP- und Importdateien", "prefs.max_file_size_web": "Die Web-App öffnet STEP- und Importdateien bis 512 MB und Designs bis 256 MB: Ein Browser-Tab kann keine größeren Dateien aufnehmen. In der Desktop-App lassen sich diese Grenzen erhöhen.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Große Symbole", + "prefs.menu_list": "Liste", + "prefs.menus": "Menüs", + "prefs.minutes": "Minuten", "prefs.minutes_suffix": " min", + "prefs.mouse": "Maus und Trackpad", + "prefs.mouse_heading": "Maus", + "prefs.mouse_mapping": "Mausbelegung", + "prefs.navigation": "Navigation", + "prefs.navigation_note": "Legt fest, wie Maus, Trackpad und 3D-Maus die Ansicht steuern.", + "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.", + "prefs.network_locations_section": "Netzwerkspeicherorte", + "prefs.not_on_gpu": "Auf dieser GPU nicht verfügbar", + "prefs.on": "An", + "prefs.opaque": "Undurchsichtig", + "prefs.orbit_snap": "Halten Sie beim Drehen die Umschalttaste gedrückt, um die Ansicht an einer Achse einrasten zu lassen.", + "prefs.orientation": "Ausrichtung", "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.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", + "prefs.placement": "Platzierung", + "prefs.prefer_free_sketch_points": "Bei überlappenden Punkten bewegliche Skizzenpunkte bevorzugen", + "prefs.preview_choices": "Vorschau Ihrer Einstellungen", + "prefs.print_face_highlight": "Hervorhebung der Auflagefläche", + "prefs.print_face_highlight_tip": "Wie stark die Auflagefläche eines Körpers in der Ansicht eingefärbt und umrandet wird. 0 % zeigt keine Hervorhebung.", "prefs.profile_active": "Aktives Profil", "prefs.profile_applied": "Profil angewendet.", "prefs.profile_changed": "Die Einstellungen wurden seit dem Speichern des Profils geändert. „Speichern“ übernimmt sie ins Profil.", @@ -1871,24 +1995,62 @@ "prefs.profiles_note": "Tastenkürzel, Werkzeugleiste, Navigation, Radialmenü und Oberflächeneinstellungen, gemeinsam unter einem Namen gespeichert und auf einmal umgeschaltet.", "prefs.project_options_dialog": "Project-Optionendialog", "prefs.project_options_dialog_note": "Ein: Vor dem Projizieren Geometrie oder Körper und die Projektionsverknüpfung auswählen. Aus: Den bisherigen Direktwahl-Ablauf beibehalten.", + "prefs.quality_default": "Standard", + "prefs.quality_fastest": "Am schnellsten", + "prefs.quality_slowest": "Am langsamsten", + "prefs.quality_smoother": "Stärker geglättet", + "prefs.quick_measure": "Schnellmessung in der Ecke der Ansicht", + "prefs.quick_measure_note": "Zeigt unten rechts in der Ansicht die Größe der Auswahl an: bei einer Kante die Länge, bei einer kreisförmigen Kante zusätzlich Durchmesser (Ø) und Radius (R), bei einer Fläche den Flächeninhalt, bei einem Punkt die Position, bei zwei Elementen Abstand und Winkel sowie bei mehreren Kanten oder Flächen die Gesamtlänge bzw. Gesamtfläche. Ein Klick kopiert den Messwert.", + "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_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", + "prefs.saving": "Automatisches Speichern und Wiederherstellung", + "prefs.scale_note": "Die Skalierung lässt sich auch mit Strg + / Strg − im Fenster ändern. Bei 100 % sind die Schaltflächen 28 px groß.", + "prefs.scale_text": "Skalierung und Text", "prefs.search": "Einstellungen suchen", "prefs.search_empty": "Keine passenden Einstellungen", + "prefs.select": "Auswählen", + "prefs.select_one_item": "Ein Element auswählen", + "prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen", + "prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.", + "prefs.selecting": "Auswahl", + "prefs.shift": "Umschalt", + "prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um", + "prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.", + "prefs.shortcuts": "Tastenkürzel", "prefs.single_surface_analysis": "Nur eine Flächenanalyse verwenden", "prefs.single_surface_analysis_help": "Wenn deaktiviert, können Flächenschattierung und Stetigkeitsanalyse gleichzeitig aktiv bleiben. Beim Aktivieren bleibt die zuletzt gestartete oder geänderte Analyse erhalten.", "prefs.sketch_align_points": "Beim Zeichnen an anderen Skizzenpunkten ausrichten", "prefs.sketch_align_points_note": "Beim Zeichnen richtet sich ein Punkt zugleich waagerecht an einem Skizzenpunkt und senkrecht an einem anderen aus, mit einer gepunkteten Hilfslinie; der Klick behält beide Ausrichtungen. Aus: nur am vorherigen Punkt, jeweils eine Achse.", "prefs.sketch_conflict_colour": "Widersprüchliche Geometrie in der Konfliktfarbe", "prefs.sketch_conflict_colour_note": "Solange die Bedingungen einer Skizze im Konflikt stehen, werden auch die Kurven und Punkte, die eine nicht erfüllte Bedingung hält, in der Konfliktfarbe gezeichnet, nicht nur ihr Symbol oder ihre Bemaßung. Aus: Sie behalten ihre üblichen Farben.", + "prefs.sketch_construction_lines": "Erweiterte Konstruktionslinien-Werkzeuge", + "prefs.sketch_exit_view": "Ansicht nach dem Beenden einer Skizze", + "prefs.sketch_exit_view.keep": "Aktuelle Ansicht beibehalten", + "prefs.sketch_exit_view.previous": "Zur vorherigen Ansicht zurückkehren", + "prefs.sketch_exit_view.unless_rotated": "Zurückkehren, sofern nicht gedreht", + "prefs.sketch_exit_view_note": "Legt fest, zu welcher Ansicht nach „Skizze beenden“, „Abbrechen“ oder zweimaligem Drücken der Esc-Taste gewechselt wird.", + "prefs.sketch_open_look_at": "Beim Öffnen einer Skizze auf sie ausrichten", + "prefs.sketch_open_look_at_note": "Aus: Beim Erstellen oder Bearbeiten einer Skizze bleibt die Ansicht, wie sie ist. „Ansehen“ richtet die Ansicht bei Bedarf auf die Skizze aus.", "prefs.sketch_recent_snap": "Automatisch an zuletzt verwendete Mittelpunkte fangen", "prefs.sketch_recent_snap_note": "Fängt den Zeiger beim Zeichnen dort ein, wo sich die Hilfslinien der beiden zuletzt verwendeten Kreismittelpunkte oder Kantenmitten kreuzen. Aus: die Hilfslinien bleiben sichtbar und der Zeiger bewegt sich nicht.", + "prefs.sketch_selection": "Skizzen und Auswahl", + "prefs.sketch_selection_heading": "Skizzen und Auswahl", + "prefs.sketch_selection_note": "Legt fest, wie Skizzenelemente einrasten und wie Klicks Elemente auswählen.", "prefs.sketch_state_icons": "Skizzensymbole in der Farbe ihres Zustands", "prefs.sketch_state_icons_note": "Im Verlauf und im Browser nimmt das Symbol einer Skizze die Farbe ihres Zustands an (unterbestimmt, vollständig bestimmt oder im Konflikt), und ihr Tooltip nennt den Zustand. Aus: Alle Skizzen haben dasselbe Symbol.", "prefs.sketch_tab_always": "Registerkarte Skizze immer zeigen", "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.sketching": "Skizzieren", + "prefs.snap_drawing_note": "Gilt beim Zeichnen oder Verschieben eines Skizzenpunkts.", + "prefs.source": "Quelle", + "prefs.speed": "Geschwindigkeit", + "prefs.stand_up_note": "Wenn eine STEP- oder IGES-Datei mit der Z-Achse nach oben geöffnet wird, wird eine Vierteldrehung um die X-Achse angeboten.", "prefs.stand_up_unknown": "Für Dateien aus unbekannten Programmen", "prefs.stand_up_unknown.ask": "In einem Dialog fragen", "prefs.stand_up_unknown.note": "Einen Hinweis zeigen", @@ -1896,6 +2058,13 @@ "prefs.stand_up_unknown_note": "Wie das Aufrichten angeboten wird, wenn der Dateikopf kein Programm nennt, das bekanntlich mit Y nach oben speichert. Ein Hinweis erscheint nur, wenn die Datei auf dem Rücken zu liegen scheint; „In einem Dialog fragen“ fragt wie bisher bei jeder solchen Datei.", "prefs.status_bar": "Statusleiste", "prefs.status_bar_note": "Eine schmale Leiste am unteren Fensterrand zeigt, was gerade bearbeitet wird, den Zustand der offenen Skizze in seiner Farbe und die Einheiten der Konstruktion; ein Klick auf die Einheiten ändert sie. Der Zustand der Skizze steht dann nicht mehr in der Ecke der Ansicht.", + "prefs.status_done": "Fertig", + "prefs.status_error": "Fehler", + "prefs.status_selected": "Ausgewählt", + "prefs.status_warning": "Warnung", + "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", "prefs.theme_file_or_paste": "Datei oder Einfügen", "prefs.theme_follow": "Einer Designdatei folgen", "prefs.theme_follow_error": "Nicht angewendet: {error}. Das letzte gültige Design bleibt aktiv.", @@ -1906,27 +2075,95 @@ "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_paste": "Design einfügen", "prefs.theme_paste_none": "Die Zwischenablage enthält kein SolidCraft-Design", + "prefs.timeline_half_hour": "−30 min", + "prefs.timeline_now": "Jetzt", + "prefs.timeline_quarter_hour": "−15", "prefs.toast_place": "Hinweise", + "prefs.toggle_item": "Element hinzufügen oder wieder entfernen", + "prefs.toolbar": "Werkzeugleiste", "prefs.toolbar_tabs": "Werkzeugleisten-Registerkarten", + "prefs.top": "Oben", "prefs.trackpad_zoom_modifier": "Mit zwei Fingern zoomen bei gedrückter Taste", "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", "prefs.trackpad_zoom_modifier.cmd": "⌘", "prefs.trackpad_zoom_modifier.ctrl": "Strg", "prefs.trackpad_zoom_modifier.help": "Nur diese Taste gedrückt halten, um durch vertikales Scrollen am Zeiger zu zoomen. Aus behält das bisherige Scrollverhalten bei.", "prefs.trackpad_zoom_modifier.off": "Aus", + "prefs.translucent": "Durchscheinend", + "prefs.under_browser": "Unter dem Browser", + "prefs.under_cursor": "Unter dem Cursor", + "prefs.units_precision": "Einheiten und Genauigkeit", + "prefs.updates": "Updates", + "prefs.value_boxes_in_view": "Werte in der Ansicht neben ihren Griffen zeigen", + "prefs.value_boxes_in_view_tip": "Der Wert eines Befehls (Abstand, Radius …) in einem Feld neben seinem Griff, über dem Modell. Aus: Sie geben ihn im Befehlsfenster ein, das nach einer Auswahl die Tastatur erhält, und nichts verdeckt das Modell.", + "prefs.view": "Ansichtsverhalten", + "prefs.view_confirm_corner": "OK und Abbrechen in der Ansichtsecke", + "prefs.view_confirm_corner_note": "Solange ein Dialog oder eine Skizze geöffnet ist, übernehmen ein Häkchen und ein Kreuz oben rechts in der Ansicht dieselben Funktionen wie „OK“ und „Abbrechen“ des Dialogs. Eine Skizze wird damit beendet oder verworfen. Der Ansichtsnavigator rückt darunter.", + "prefs.viewport": "Ansichtsfenster", + "prefs.viewport_analysis_status": "Analysestatus im Ansichtsfenster anzeigen", + "prefs.viewport_analysis_status_help": "Zeigt aktive Analysen über dem Ansichtsfenster und ihre Legenden am jeweiligen Eintrag. Esc beendet die neueste Analyse. Ausschalten stellt Ecklegenden und das bisherige Esc-Verhalten wieder her.", "prefs.viewport_background": "Hintergrund", "prefs.viewport_background_colour": "Farbe", "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_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", + "prefs.wheel_trackpad": "Mausrad und Trackpad", + "prefs.workspace": "Arbeitsbereich", "prefs.zoom_stop_at_model": "Zoom am Modell stoppen", "prefs.zoom_stop_at_model_tip": "3D-Maus und Geräte-App zoomen zur Oberfläche in der Ansichtsmitte; Zieh-Zoom hält den Punkt am Beginn des Ziehens fest. Aus: Zoom kann durch das Modell gehen. Mausrad und Pinch verwenden separat Zoom zum Mauszeiger", + "prefs.zoom_toward_cursor": "Zoomen zum Mauszeiger hin", "properties.title": "Eigenschaften: {name}", "recovery.days_ago": "vor {count} Tagen", "recovery.detail": "{file} · {features} Merkmale · automatisch gespeichert {when}", + "recovery.discard_all": "Alle verwerfen", + "recovery.discard_all_detail": "Dies kann nicht rückgängig gemacht werden.", + "recovery.discard_all_title": "{count} automatische Sicherungen verwerfen?", + "recovery.discard_all_title_one": "1 automatische Sicherung verwerfen?", "recovery.hours_ago": "vor {count} Std.", "recovery.just_now": "gerade eben", "recovery.min_ago": "vor {count} Min.", "recovery.never_saved": "nie gespeichert", + "render.adaptive_absolute": "Absolute RGB-Fehleruntergrenze", + "render.adaptive_coverage": "Zielwert für den Abdeckungsfehler", + "render.adaptive_enable": "Samples mithilfe unabhängiger Pilot-Samples zuteilen", + "render.adaptive_help": "Nur GPU. Unabhängige Pilot-Samples werden verworfen. Anschließend werden pro Pixel 16 oder mehr Produktions-Samples zugeteilt, bis zu den Samples pro Pixel. Die Zuteilung richtet sich nach der RGB- und Abdeckungsvarianz; seltene Pfade können übersehen werden, und Pixel, die ihr Limit erreicht haben, können verrauscht bleiben. Der Abschlussbericht zählt den tatsächlichen Aufwand. Keine zertifizierte Garantie für ein bestimmtes Rauschniveau oder eine bestimmte Renderzeit.", + "render.adaptive_minimum": "Minimale Produktions-Samples", + "render.adaptive_pending": "Die adaptive Zuteilung mit unabhängigen GPU-Pilot-Samples und bedingte Fresnel-Schichten sind verfügbar. Zertifizierte Konvergenz, Garantien für seltene Ereignisse und adaptiver Lichttransport auf der CPU bleiben nicht verfügbar.", + "render.adaptive_pilots": "Pilot-Samples pro Pixel", + "render.adaptive_relative": "Zielwert für den relativen RGB-Fehler", "render.add_ground": "Add fitted ground to setup", + "render.advanced_material_scope": "Ein endliches, homogenes GPU-Medium ist innerhalb ausdrücklich festgelegter Grenzen verfügbar. Dichtegitter, verschachtelte Glas-/Volumen-Kombinationen, Subsurface-Oberflächen, Displacement, spektraler Lichttransport und nicht zugeordnete erweiterte Materialkonzepte bleiben nicht verfügbar.", + "render.aging_add": "Alterungsschicht hinzufügen", + "render.aging_airborne": "Luftgetragener Staub / teilnehmendes Medium", + "render.aging_airborne_help": "Luftgetragener Staub bildet ein separates, endliches und homogenes Streuvolumen. Er beeinflusst den Lichttransport und das unbearbeitete EXR-Bild, nicht jedoch die Oberflächenalterungsschichten. Die bestehenden Einschränkungen für Volumen, Glas und Entrauschen gelten weiterhin. Eine Atmosphäre wird nicht automatisch hinzugefügt.", + "render.aging_all_bodies": "Alle geeigneten undurchsichtigen Körper", + "render.aging_chips": "Abgeplatzter Lack", + "render.aging_class_any": "Alle undurchsichtigen Materialklassen", + "render.aging_class_dielectric": "Nur nichtmetallische Oberflächen", + "render.aging_class_metal": "Nur metallische Oberflächen", + "render.aging_color": "Schichtfarbe · sRGB", + "render.aging_coverage": "Abdeckung", + "render.aging_dust": "Abgelagerter Staub", + "render.aging_empty": "Keine Alterungsschichten: Die ursprünglichen Oberflächen bleiben unverändert.", + "render.aging_fading": "Ausgeblichene Beschichtung", + "render.aging_grime": "Schmutz und Verfärbungen", + "render.aging_help": "Bis zu acht optische Schichten der Reihe nach kombinieren. Rost und Patina sind gestaltete Erscheinungsbilder, keine Simulationen von Alterung oder Korrosion. Die Mustergröße wird in Weltmillimetern angegeben. Geometrie, Masse und technische Eigenschaften bleiben unverändert; Glas und passive Bühnenflächen sind ausgenommen.", + "render.aging_metallic": "Metallanteil der Schicht", + "render.aging_origin": "Feldursprung · Weltkoordinaten (mm)", + "render.aging_patina": "Kupfergrüne Patina", + "render.aging_refresh_targets": "Aktuelle Körper prüfen", + "render.aging_roughness": "Rauheit der Schicht", + "render.aging_rust": "Oxidiertes Eisen / Rost", + "render.aging_scale": "Mustergröße", + "render.aging_scratches": "Feine Kratzer", + "render.aging_target": "Ziel und Koordinaten", + "render.aging_target_help": "Ein benanntes Ziel muss weiterhin zu seinem erfassten Index und Namen passen. Globale Masken sind im Weltkoordinatensystem verankert und folgen bewegten Teilen nicht. Die Funktion erkennt weder Hohlräume noch echten Kantenverschleiß. Filtern Sie die Liste, um weitere Körper zu erreichen.", + "render.aging_target_search": "Körpernamen filtern", + "render.aging_upward": "Ablagerung auf nach oben zeigenden Flächen", + "render.all_controls_help": "Kompakte Übersicht aller aktuellen Einstellungen. Die Abschnittsseiten erlauben eine gezielte Bearbeitung, ohne die zugrunde liegende Anfrage zu ändern.", "render.all_shots_label": "Alle Aufnahmen", "render.appearance_editor.absorption": "Absorption", "render.appearance_editor.base_colour": "Grundfarbe", @@ -1967,18 +2204,64 @@ "render.appearance_editor.v_repeat": "V-Wiederholung", "render.appearance_editor.v_up": "Textur-V zeigt nach oben", "render.appearance_editor.weight": "Gewicht", + "render.appearance_physics_separate": "Das visuelle Aussehen weist weder Dichte noch thermische Daten oder andere technische Eigenschaften zu.", "render.appearances": "Erscheinungsbilder", "render.assign_help": "Klicken wendet den Look auf die Auswahl an. Ziehen zeigt eine Vorschau; ohne Auswahl startet Malen.", + "render.availability_native": "Native Zusammenstellung; vor dem Anwenden prüfen", + "render.availability_projection": "Unterstützte Projektion; Umfang und Auslassungen beachten", + "render.availability_reference": "Referenzdaten; keine automatische Zuweisung", + "render.backdrop_alpha_conflict": "Wählen Sie entweder diesen Hintergrund oder die Ausgabe als transparentes PNG; der Renderer ignoriert keine der beiden Einstellungen stillschweigend.", + "render.backdrop_enable": "Verlaufshintergrund nur für die Kamera verwenden", + "render.backdrop_first": "Start-/Mittelfarbe", + "render.backdrop_help": "Dieser Hintergrund im Bildschirmraum beleuchtet nicht, wirft keine Schatten, spiegelt nicht und stellt kein Gelände dar. Er ist im unbearbeiteten EXR-Bild nicht enthalten. Geometrie, Lichter und die HDR-Umgebung bleiben unabhängig.", + "render.backdrop_horizontal": "Horizontaler Verlauf", + "render.backdrop_radial": "Radialer Verlauf", + "render.backdrop_radius": "Radius", + "render.backdrop_second": "End-/Randfarbe", + "render.backdrop_vertical": "Vertikaler Verlauf", + "render.backdrop_x": "Mittelpunkt X", + "render.backdrop_y": "Mittelpunkt Y", "render.background": "Szenenhintergrund", + "render.background_camera_section": "Kamerahintergrund · PNG-Darstellung", + "render.background_completed_frozen": "Das angezeigte Ergebnis ist ein eingefrorenes, fertiges Bild. Eine Änderung des Hintergrunds zeichnet dieses Ergebnis nicht automatisch neu. Verwenden Sie „Neu graden“ für reine Kameraänderungen oder „Rendern“ für eine neue physische Bühne.", + "render.background_hdr_separate": "Die aktuellen HDR-Ressourcen beleuchten Oberflächen und spiegeln sich darin; sie werden nicht automatisch als Panorama hinter dem Motiv angezeigt. Das Ändern eines Verlaufs dreht oder verfärbt die Beleuchtung nicht.", + "render.background_lighting_section": "HDR-Beleuchtung · getrennt vom Kamerahintergrund", + "render.background_no_alpha_override": "Ein Kamerahintergrund wird einer transparenten Ausgabe nicht stillschweigend hinzugefügt. Beim Neu-Graden des linearen Rohbilds wird die erfasste Abdeckung verwendet und das Entrauschen nicht erneut angewendet; das Quell-EXR bleibt unverändert.", + "render.background_not_light": "Die Hintergrundfarbe dient der Darstellung und ist keine zusätzliche Lichtquelle.", + "render.background_regrade": "Auf das behaltene lineare Rohbild anwenden", + "render.background_stage_occlusion": "Diese Aufnahme enthält physische Bühnengeometrie. Ein großer Boden oder eine große Wand kann das Bild füllen und einen reinen Kamerahintergrund verdecken. Entfernen oder verkleinern Sie die Bühne gezielt oder ändern Sie ihren physikalischen Reflexionsgrad. Neu graden kann bereits erfasste Geometrie nicht entfernen.", + "render.background_workflow": "Wählen Sie den gewünschten Hintergrund: eine reine Kamerafarbe bzw. einen Verlauf, eine physische Bühne, die Licht empfängt, oder HDR-Beleuchtung. Sie sind voneinander unabhängig. Die Live-Vorschau ist eine Modellieransicht; rendern Sie ein neues Bild oder graden Sie ein behaltenes lineares Rohbild neu, um die Nachbearbeitung der Kamera zu sehen.", + "render.base_appearance": "Bearbeitbares Aussehen des Körpers", + "render.base_apply": "Aussehen des Körpers anwenden", + "render.base_apply_help": "Eine rückgängig machbare visuelle Zuweisung an genau den geprüften Körper. Bestehende flächenspezifische Looks und technische Materialien bleiben davon getrennt.", + "render.base_color": "Grundfarbe · sRGB", + "render.base_metallic": "Metallanteil", + "render.base_opaque_only": "Dieser Editor lässt Körper undurchsichtig. Für optische Transmission verwenden Sie die gesonderten Glaseinstellungen.", "render.bloom_enable": "Darstellungs-Bloom", "render.bloom_help": "Kreativer Ausgabeglanz aus linearen Glanzlichtern mit drei normierten Durchläufen. Radius in Ausgabepixeln; Addition vor sRGB. Keine Linsen-, Volumen- oder Konvergenzsimulation. Das rohe EXR bleibt unverändert.", "render.bloom_opaque": "Bloom benötigt eine deckende Ausgabe. Transparentes PNG wird abgelehnt, nicht still abgeflacht.", "render.bloom_radius": "Bloom-Radius", "render.bloom_strength": "Bloom-Stärke", "render.bloom_threshold": "Linearer Glanzlichtschwellwert", + "render.browse_capabilities": "Vollständigen Funktionskatalog ansehen", + "render.browse_environments": "Originale HDR-Umgebungen durchsuchen", "render.browse_looks": "Durchsuchen…", + "render.browse_native_scenes": "Native Studios durchsuchen", + "render.browse_physical": "Physikalische Referenzen ansehen", + "render.browse_quality": "Qualitätsspezifikationen ansehen", + "render.browse_rigs": "Erstellte Lichtaufbauten durchsuchen", + "render.browse_scene_specs": "Erweiterte Szenenspezifikationen durchsuchen", + "render.browse_surfaces": "Erstellte Oberflächen durchsuchen", + "render.browser_library": "Bibliothek", + "render.browser_model": "Modell", + "render.camera_distance": "Abstand zwischen Kamera und Ziel", + "render.camera_exact_help": "Weltmillimeter; Winkel werden in Grad angezeigt und als Bogenmaß gespeichert. Die Navigation verwendet dieselbe Kamera wie die Ansicht.", + "render.camera_fov": "Vertikaler Bildwinkel", "render.camera_pitch": "Kameraneigung", + "render.camera_roll": "Rollwinkel", "render.camera_set": "Render-Kamera einstellen", + "render.camera_target": "Zielposition · mm", + "render.camera_yaw": "Gierwinkel", "render.category_metal": "Metall", "render.category_paint": "Lack", "render.category_plastic": "Kunststoff", @@ -2062,7 +2345,22 @@ "render.children.vignette": "Vignette", "render.children.workstation": "Workstation", "render.choose_look": "Zuerst einen Look in der Bibliothek wählen", + "render.chromatic_offset": "Chromatischer Kantenversatz", "render.clear_stage": "Remove saved stage", + "render.copy_request": "JSON kopieren", + "render.cpu_aa_help": "Räumliche Samples: Mehr Samples verbessern die Kantenglättung, verlängern jedoch die Renderzeit.", + "render.cpu_area_add": "Rechteckiges CPU-Flächenlicht hinzufügen", + "render.cpu_area_editor": "Rechteckige CPU-Flächenlichter", + "render.cpu_area_editor_help": "Vollständige Seitenvektoren in Millimetern; das Kreuzprodukt U × V zeigt zur abstrahlenden Seite. Das unterscheidet sich von den halben Kantenvektoren der GPU.", + "render.cpu_area_full_u": "Volle Seite U · mm", + "render.cpu_area_full_v": "Volle Seite V · mm", + "render.cpu_area_samples": "Flächenlicht-Samples", + "render.cpu_binding_confirm": "Diesen Körper bestätigen", + "render.cpu_binding_stale": "Die Szene hat sich geändert. Bestätigen Sie dieses Reflexionsziel vor dem Rendern erneut.", + "render.cpu_body_choose": "Körper wählen", + "render.cpu_body_filter": "Körper nach Namen filtern", + "render.cpu_body_filter_more": "Verfeinern Sie den Filter, um weitere Körper zu sehen.", + "render.cpu_body_missing": "Ein Reflexionsziel fehlt, ist ausgeblendet, mehrdeutig oder hat einen Namen von mehr als 128 Byte. Wählen Sie es erneut.", "render.cpu_budget": "CPU-Ressourcenprofil", "render.cpu_budget_bodies": "Körperobergrenze", "render.cpu_budget_compact": "Kompakt (bisherige Grenzen)", @@ -2074,7 +2372,77 @@ "render.cpu_budget_studio": "Studio (große Bilder)", "render.cpu_budget_triangles": "Dreiecksobergrenze", "render.cpu_budget_workstation": "Workstation (komplexe Szenen)", + "render.cpu_captured_hdr": "Für dieses Bild erfasstes HDR", + "render.cpu_check_capture_help": "Prüfen und Starten verifizieren dieselbe Datei-Prüfsumme oder verwenden das eingebettete HDR. Jedes Bild behält seine erfasste Quelle.", + "render.cpu_check_changed": "Die Einstellungen oder das Modell haben sich nach dieser Prüfung geändert. Prüfen Sie erneut, bevor Sie rendern.", + "render.cpu_constant_help": "Konstante Strahldichte ist für reflektierte oder durchgelassene Strahlen sichtbar. Sie liefert keine direkte diffuse Beleuchtung.", + "render.cpu_custom_directions": "Vorgabe durch eigene Richtungslichter ersetzen", + "render.cpu_direction": "Richtung zum Licht (X, Y, Z)", + "render.cpu_directional": "Richtungslicht", + "render.cpu_directional_add": "Richtungslicht hinzufügen", + "render.cpu_environment": "CPU-Umgebung", + "render.cpu_environment_background": "Der Bildhintergrund bleibt von der Umgebungsbeleuchtung getrennt.", + "render.cpu_environment_bounds": "Die CPU-Intensität muss 0–64 betragen, die HDR-Drehung endlich innerhalb von ±1.000.000 Radiant liegen, und die HDR-Samples müssen 1, 4, 16 oder 64 sein.", + "render.cpu_environment_constant": "Konstante Umgebung für Sekundärstrahlen", + "render.cpu_environment_none": "Keine CPU-Umgebung", + "render.cpu_environment_samples": "HDR-Beleuchtungs-Samples", + "render.cpu_exposure_help": "Die CPU-Belichtung muss zwischen −16 und 16 EV liegen. Eine GPU-Belichtung außerhalb dieses Bereichs bleibt erhalten, bis Sie sie ändern.", + "render.cpu_fixed": "Einzelbild", + "render.cpu_hdr_help": "Radiance-HDR-Datei (.hdr), Panorama im Format 2:1, bis zu 2048 × 1024 Pixel und 16 MiB. Für die CPU müssen die sRGB/D65-Primärfarben ausdrücklich in der Datei angegeben sein. Dateien ohne Angabe der Primärfarben sowie EXR-Dateien werden abgelehnt.", + "render.cpu_hdr_reflection_help": "Raue HDR-Reflexionen erfordern vollständig metallische Körper- und Flächenoberflächen. Die Prüfung meldet nicht unterstützte Kombinationen.", + "render.cpu_key_rim": "Haupt- und Konturlicht", + "render.cpu_last_check": "Letzte CPU-Prüfung", + "render.cpu_light_help": "Bis zu acht Richtungslichter. Eine leere eigene Liste schaltet sie aus; die Wahl einer Vorgabe stellt deren Lichter wieder her. Farben sind lineares RGB, und die Intensität ist ein relativer Wert, keine Leistungsangabe in Watt.", + "render.cpu_light_intensity": "Relative Intensität (0–64)", + "render.cpu_light_minimum": "Mindestabstand für die Abschwächung", + "render.cpu_light_position": "Weltposition (X, Y, Z)", + "render.cpu_light_reference": "Referenzabstand für die Intensität", + "render.cpu_light_remove": "Licht entfernen", + "render.cpu_lighting": "Beleuchtungsvorgabe", + "render.cpu_lights": "CPU-Lichter", + "render.cpu_linear_color": "Lineare Farbe (R, G, B)", + "render.cpu_neutral": "Neutral", + "render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.", + "render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.", + "render.cpu_point": "Punktlicht", + "render.cpu_point_add": "Punktlicht hinzufügen", + "render.cpu_point_help": "Bis zu acht Punktlichter, zusätzlich zur gewählten Richtungsbeleuchtung. Abstände in mm; der Mindestabstand darf den Referenzabstand nicht überschreiten.", + "render.cpu_primary_samples": "Kanten-Samples pro Pixel", + "render.cpu_progressive": "CPU progressiv", + "render.cpu_progressive_help": "Den vorhandenen CPU-Integrator in Durchgängen mit je einem Primär-Sample akkumulieren. Ausgewählte Reflexionen erfordern ein Sample; Flächenlichter, Dielektrika und Anisotropie erfordern „Einzelbild“.", + "render.cpu_progressive_limits": "Höchstens 1.048.576 Pixel und 16.777.216 Auswertungen von Pixel × Sample. Das Ändern der Samples passt die Bildgröße nie automatisch an.", + "render.cpu_progressive_seed": "Startwert für das CPU-Sampling", + "render.cpu_progressive_spp": "Samples pro Pixel", + "render.cpu_reflection_add": "Raue Reflexion hinzufügen", + "render.cpu_reflection_depth": "Reflexionstiefe (1–8)", + "render.cpu_reflection_empty": "Keine Körper ausgewählt: Szenenreflexionen sind aus.", + "render.cpu_reflection_help": "Wählen Sie Körper und stellen Sie „Aus“, „Spiegelnd“ oder „Rau“ ein. „Rau“ verwendet die zugewiesene Oberflächenrauheit. Mehr Samples und Reflexionsstufen verlängern die Rechenzeit; dies ist begrenzter spiegelnder Transport, keine globale Beleuchtung.", + "render.cpu_reflection_limit": "Bis zu 64 reflektierte Körper werden unterstützt.", + "render.cpu_reflection_mirror": "Spiegelnd", + "render.cpu_reflection_off": "Aus", + "render.cpu_reflection_rough": "Rau", + "render.cpu_reflection_samples": "Reflexions-Samples (1–64)", + "render.cpu_reflections": "CPU-Reflexionen", + "render.cpu_runtime_help": "Numerische Licht- und Umgebungseinstellungen werden mit dem Setup gespeichert. Körperreflexionen und HDR-Dateien bleiben vorübergehend. Vorhandene Werte für Flächenlicht und Spotkegel bleiben erhalten.", + "render.cpu_sampling_mode": "CPU-Sampling-Modus", + "render.cpu_shadows": "Schatten werfen", + "render.cpu_summary": "CPU-Raytracing · Reflexionen · HDR · Richtungs- und Punktlichter", + "render.cpu_warm_cool": "Warm und kühl", + "render.diag_bodies": "Sichtbare Körper", + "render.diag_job_details": "Erfasste Auftragsdetails", + "render.diag_last_check": "Letzte Vorabprüfung", + "render.diag_memory": "Geschätzter logischer Speicherbedarf", + "render.diag_not_implemented": "Ausdrückliche Funktionsgrenzen", + "render.diag_preflight_details": "Details der Vorabprüfung", + "render.diag_request": "Aktuelle Anfrage im Wortlaut", + "render.diag_triangles": "Dreiecke", + "render.discard_continue": "Entwurf verwerfen und fortfahren", + "render.draft_confirm_help": "Der aktuelle Render-Entwurf enthält nicht angewendete Änderungen. Speichern Sie sie vor dem Wechsel, verwerfen Sie sie ausdrücklich oder bearbeiten Sie weiter.", + "render.draft_confirm_title": "Bearbeitete Rendereinstellungen beibehalten", "render.effects_gpu_only": "Diese Effekte erfordern GPU-Pfadverfolgung. Ein Seitenwechsel schaltet den Renderer niemals automatisch um.", + "render.effects_help": "Geordnete Farb-Looks und Objektiveffekte im Bildraum kombinieren. Das Rendern und das Neu-Graden des linearen Rohbilds verwenden dieselben gespeicherten Einstellungen. Das unbearbeitete EXR bleibt unverändert. Dies sind eigene künstlerische Regler, keine kalibrierten Objektiv- oder Filmemulationen bestimmter Marken.", + "render.effects_reset_lens": "Objektiveffekte zurücksetzen", + "render.effects_seed": "Startwert des Musters", "render.emitted_axis": "Abstrahlachse", "render.env_advanced": "Erweitert", "render.env_backdrop": "Hintergrund", @@ -2214,11 +2582,19 @@ "render.error_vertex_packing": "Produktvorschau stimmt nicht mit den zugelassenen Scheitelpunktdaten überein", "render.error_visible_solid": "In Design einen sichtbaren Volumenkörper erstellen, um ihn hier anzuzeigen.", "render.exit_focus": "Fokus beenden", + "render.export_display": "Export: Anzeige-PNG", "render.export_image_label": "Bild exportieren", + "render.export_linear": "Export: lineares Roh-EXR", "render.exr_pending": "Die GPU kann rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.", + "render.film_grain": "Fotografisches Korn", "render.fit": "Produkt einpassen", "render.fixed_camera": "Kamera", "render.fixed_world": "Welt", + "render.flare_ghosts": "Geisterringe pro Quelle", + "render.flare_halo": "Reflex-Lichthof", + "render.flare_streak": "Streifen im anamorphen Stil", + "render.flare_strength": "Lichtreflex-Stärke an Glanzlichtern", + "render.flare_threshold": "Szenenlinearer Lichtreflex-Schwellwert", "render.fold_browser": "Browser einklappen", "render.frame_aspect": "Seitenverhältnis", "render.frame_centre_mark": "Mittelpunkt", @@ -2247,6 +2623,27 @@ "render.frame_tall": "9:16 · Vertikal", "render.frame_thirds": "Drittellinien", "render.frame_wide": "16:9 · Breitbild", + "render.gpu_depth": "Maximale Pfadtiefe", + "render.gpu_options_help": "Samples, Tiefe, Startwert und Belichtung werden mit diesem Setup gespeichert. Es wird nur die aktive Bildmethode gespeichert.", + "render.gpu_path_failed": "GPU-Pathtracer nicht verfügbar", + "render.gpu_path_first_setup": "Das erste Setup auf dieser GPU kann mehrere Minuten dauern; SolidCraft bleibt währenddessen bedienbar. Spätere Starts verwenden die kompilierten Shader erneut, sofern der Treiber sie behält.", + "render.gpu_path_preparing": "GPU-Pathtracer: Shader werden kompiliert…", + "render.gpu_seed": "Zufallsstartwert", + "render.gpu_unavailable": "GPU-Pathtracing ist auf diesem Gerät nicht bereit.", + "render.ground_apply": "Boden anpassen und speichern", + "render.ground_gap": "Definierter Abstand", + "render.ground_linear_rgb": "Diffuser Reflexionsgrad · lineares RGB", + "render.ground_margin": "Randzugabe der Bodenfläche", + "render.hdr_details": "Details zur HDR-Quelle", + "render.hdr_embedded": "In diesem Design gespeichertes HDR", + "render.hdr_embedded_help": "Das Rendern verwendet die im Design enthaltenen Bytes; die Originaldatei wird nicht mehr benötigt.", + "render.hdr_file_captured": "HDR-Datei geprüft. Beim Speichern werden diese Bytes eingebettet; ändert sich die Datei, prüfen Sie sie erneut ausdrücklich.", + "render.hdr_inspect": "HDR-Datei prüfen", + "render.hdr_inspect_required": "Wählen und prüfen Sie eine HDR-Datei, bevor Sie speichern oder rendern.", + "render.hdr_primaries_declared": "Die Quelle gibt ihre Primärfarben an.", + "render.hdr_primaries_srgb": "Primärfarben: sRGB/D65", + "render.hdr_primaries_undeclared": "Die Quelle gibt keine Primärfarben an; prüfen Sie die Farbanforderungen des Renderers.", + "render.hdr_resource_missing": "In diesem Design fehlt die gespeicherte HDR-Ressource.", "render.heading_batch": "Stapel", "render.heading_frame": "Bildrahmen", "render.heading_lens": "Objektiv", @@ -2254,19 +2651,26 @@ "render.heading_shots": "Aufnahmen", "render.hide_shots_label": "Aufnahmenleiste ausblenden", "render.hover_look": "{object} · {look} · Zum Auswählen klicken", + "render.image_advanced": "Erweiterte GPU-Einstellungen", "render.image_cancelled": "Rendering abgebrochen", "render.image_cancelling": "Wird abgebrochen", "render.image_completed": "Bild bereit zum Export", "render.image_encoding": "PNG wird kodiert", "render.image_export": "Fertiges Bild exportieren", + "render.image_exposure": "Belichtung", "render.image_failed": "Rendering fehlgeschlagen", "render.image_height": "Höhe", "render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz", + "render.image_host_missing": "Es ist kein Bild-Host installiert.", + "render.image_method": "Bildmethode", "render.image_overwrite": "Vorhandene Datei ersetzen", "render.image_preparing": "Bild wird vorbereitet", + "render.image_progressive": "Progressives Rendering", "render.image_queued": "Warten auf Rendering", "render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.", "render.image_running": "Bild wird gerendert", + "render.image_samples": "Samples pro Pixel", + "render.image_scene_stale": "Schließen Sie die Modellierungsgeste ab oder aktualisieren Sie die Szene, bevor Sie ein Bild prüfen oder starten.", "render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.", "render.image_start": "Rendering starten", "render.image_supported": "Renderanfrage unterstützt; Warteschlange und Speicher werden beim Start erneut geprüft.", @@ -2278,6 +2682,7 @@ "render.image_width": "Breite", "render.include_rig_cards": "Include passive cards and flags", "render.inner_half_angle": "Innerer Halbwinkel", + "render.inspect_scene": "Erfasste Szenenübersicht aktualisieren", "render.inspector": "Inspektor", "render.inspector_advanced": "Erweitert", "render.inspector_all_users": "Alle {count} Nutzer", @@ -2309,7 +2714,17 @@ "render.inspector_this_face": "Diese Fläche", "render.inspector_this_target": "Dieses Ziel", "render.inspector_used_by": "Verwendet von {count}", + "render.keep_editing": "Weiter bearbeiten", + "render.layer_down": "Nach unten", + "render.layer_enabled": "Schicht aktiviert", "render.layer_off_suffix": " (aus)", + "render.layer_remove": "Schicht entfernen", + "render.layer_strength": "Schichtstärke", + "render.layer_up": "Nach oben", + "render.lens_effects_help": "Lichtreflexe nutzen bis zu vier getrennte helle Quellen im tatsächlichen linearen Bild. Geisterringe, Lichthof, Streifen, chromatische Versätze und radiale Verzeichnung sind künstlerische Näherungen im Bildraum. Räumliche Effekte benötigen eine undurchsichtige Ausgabe; Samples außerhalb setzen den Bildrand fort. Das Korn ist deterministisch. Physik, EXR und Geometrie bleiben unverändert.", + "render.lens_effects_title": "Objektivinspiriert und fotografisch", + "render.library_all": "Katalog", + "render.library_all_states": "Alle Verfügbarkeitszustände", "render.library_appearance_available": "Verfügbares Erscheinungsbild", "render.library_applicable": "Passende Ressourcen", "render.library_applied": "Ressourcenvariante angewendet. Vollständigen Renderauftrag vor dem Start prüfen.", @@ -2321,10 +2736,16 @@ "render.library_apply_uniform": "Einheitliches opakes Aussehen anwenden", "render.library_authored_help": "Original erstellte Ressourcen dienen der Darstellung, nicht als gemessene technische Daten. Jede Aktion wendet nur ihre angegebene Variante an.", "render.library_blocked": "Diese Ressource benötigt noch Kalibrierung oder eine externe Sonne und kann noch nicht angewendet werden.", + "render.library_capabilities": "Funktionsspezifikationen", "render.library_center": "Mittelpunkt des Lichtaufbaus", + "render.library_clear_search": "Leeren", + "render.library_details": "Spezifikation und Herkunft", "render.library_emitters_only_ack": "Nur Strahler verwenden; dokumentierte passive Karten auslassen", + "render.library_empty": "Keine passenden Ressourcen. Versuchen Sie eine andere Suche oder einen anderen Verfügbarkeitsfilter.", "render.library_environment_concepts": "Umgebungskonzepte", "render.library_extent": "Objektausdehnung", + "render.library_favorite": "Zu Favoriten hinzufügen", + "render.library_favorites": "Favoriten", "render.library_filters": "Katalog und Verfügbarkeit", "render.library_grid": "Raster", "render.library_hdr": "Integrierte HDR-Umgebungen", @@ -2337,12 +2758,15 @@ "render.library_maps_help": "Built-in maps can be saved with pinned sources and local occurrence mapping. Temporary optical overrides remain a separate mode.", "render.library_material_concepts": "Materialkonzepte", "render.library_metallic": "Metallisch", + "render.library_native_scenes": "Native Studios", "render.library_next": "Nächste Seite", + "render.library_omitted": "Nicht enthalten", "render.library_opacity": "Deckkraft", "render.library_other": "Weitere Erscheinungsbilder", "render.library_physical_profiles": "Physical reference profiles", "render.library_previous": "Vorherige Seite", "render.library_quality_concepts": "Qualitätsspezifikationen", + "render.library_recent": "Zuletzt", "render.library_replace_environment": "Vorhandene Umgebung ausdrücklich ersetzen", "render.library_replace_studio": "Replace saved lighting, environment and stage", "render.library_results": "Passende Ressourcen", @@ -2353,12 +2777,25 @@ "render.library_scene_bounds": "Erfasste Szenengrenzen verwenden", "render.library_scenes": "Szenenspezifikationen", "render.library_search": "Bibliothek durchsuchen", + "render.library_select_hint": "Wählen Sie eine Ressource in der Bibliothek, um ihre unterstützten Aktionen und Einschränkungen zu sehen.", "render.library_selene": "SELENE: opake Oberflächen", + "render.library_selene_shots": "SELENE-Aufnahmespezifikationen", + "render.library_short_blocked": "Nicht verfügbar", + "render.library_short_native": "Native Zusammenstellung", + "render.library_short_projection": "Unterstützte Projektion", + "render.library_short_reference": "Nur Referenz", + "render.library_short_specification": "Nur Spezifikation", + "render.library_source_preview": "Vorschau der Quellmap laden", + "render.library_source_preview_help": "Erstellte Quellmap der Grundfarbe. Dies ist keine gerenderte oder gemessene Materialvorschau.", "render.library_specification_only": "Nur Spezifikation: keine vollständig ausführbare Voreinstellung. Erforderliche Effekte, Ressourcen und Objektrollen müssen noch implementiert und gebunden werden.", + "render.library_specs": "Nur Spezifikationen", + "render.library_status": "Verfügbarkeit", "render.library_surfaces": "Erstellte PBR-Oberflächen", "render.library_target": "Zielkörper", "render.library_title": "Render-Ressourcenbibliothek", + "render.library_unfavorite": "Aus Favoriten entfernen", "render.library_uniform_ack": "Einheitliche Näherung ohne räumliche oder erweiterte Effekte verwenden", + "render.library_usable": "Ausführbare Projektionen", "render.library_users": "Verwendungen", "render.light_aim": "Ausrichten auf", "render.light_anchor_pending": "Das Koordinatensystem der Lichter kann noch nicht geändert werden.", @@ -2437,9 +2874,22 @@ "render.lighting_width": "Breite U", "render.lighting_world_position": "Welt X Y Z", "render.lights_fixed_to": "Lichter gebunden an", + "render.linear_help": "Behält ein lineares 32-Bit-Gleitkomma-RGBA-Rohbild vor Entrauschen, Belichtung, Tone-Mapping, Filtern und Hintergrund. Lineares sRGB/D65; Abdeckung vormultipliziert. Zusätzlicher Speicher wird ausdrücklich zugelassen. Keine Komprimierung und keine zusätzlichen Durchgänge.", + "render.linear_retain": "Lineares Roh-EXR behalten", + "render.linear_unavailable": "Für das gewählte fertige Bild wurde kein EXR behalten. Aktivieren Sie das Behalten vor dem Rendern; ein älteres PNG kann die rohe Strahldichte nicht wiederherstellen.", + "render.look_add": "Farblook hinzufügen", "render.look_applied": "{name} angewendet", + "render.look_bleach": "Von Bleach-Bypass inspiriert", + "render.look_cool": "Kühl", + "render.look_cyanotype": "Von Cyanotypie inspiriert", + "render.look_faded": "Verblasster Abzug", "render.look_material": "Material", + "render.look_mono": "Monochrom", "render.look_pasted": "Look eingefügt", + "render.look_sepia": "Sepia", + "render.look_strength": "Look-Stärke", + "render.look_teal_orange": "Türkis / Orange", + "render.look_title": "Stapel kreativer Looks", "render.material_color_replace": "Basismap ersetzt Körperfarbe", "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_metallic_map": "Metallanteil-Map (linear)", @@ -2475,7 +2925,9 @@ "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_tools_inspector": "Größe und Format im Ausgabe-Inspektor bearbeiten.", "render.output_view_image": "Bild anzeigen", "render.paint_label": "Aufmalen", @@ -2607,6 +3059,7 @@ "render.size_custom": "Benutzerdefiniert…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · erfordert {profile}", "render.size_presets": "Größenvorlagen", "render.size_presets_pending": "Kommt mit dem Ausgabeeditor", "render.size_preview": "Vorschau", @@ -2829,6 +3282,24 @@ "sheet.flange_type_base": "Basislasche", "sheet.flange_type_contour": "Konturlasche", "sheet.flange_type_edge": "Kantenlasche", + "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.", + "sheet.personal_rules.full": "Die persönliche Bibliothek enthält bereits 64 Regeln.", + "sheet.personal_rules.invalid_name": "Geben Sie einen kurzen Namen ohne Zeilenumbrüche oder Steuerzeichen ein.", + "sheet.personal_rules.invalid_values": "Vervollständigen Sie die Werte der Regel: Jeder muss sich in diesem Entwurf berechnen lassen, mit positiver Dicke und einem K-Faktor von 0 bis 1.", + "sheet.personal_rules.missing": "Die ausgewählte persönliche Regel ist nicht mehr verfügbar.", + "sheet.personal_rules.name": "Regelname", + "sheet.personal_rules.name_exists": "Eine persönliche Regel mit diesem Namen existiert bereits. Wählen Sie einen anderen Namen.", + "sheet.personal_rules.name_hint": "Eindeutigen Namen eingeben", + "sheet.personal_rules.overwrite": "Änderungen in der ausgewählten persönlichen Regel speichern", + "sheet.personal_rules.rename": "Umbenennen", + "sheet.personal_rules.rule": "Persönliche Regel", + "sheet.personal_rules.save": "Als neue Regel speichern", + "sheet.personal_rules.scope": "In Ihrem Profil gespeichert und für alle Entwürfe verfügbar.", + "sheet.personal_rules.select": "Regel auswählen", + "sheet.personal_rules.title": "Persönliche Regeln", + "sheet.personal_rules.unsupported": "Diese Regel enthält Werte, die diese Version nicht verwenden kann.", "shortcut.json_error": "Parameter müssen JSON sein: {error}", "shortcut.no_command": "kein Befehl `{id}`; der Text vor dem JSON muss eine Befehls-ID sein", "shortcut.pin": "Oben im S-Feld anheften", @@ -2991,6 +3462,8 @@ "ui.3d_mouse_section": "3D-Maus", "ui.3d_mouse_show_in_view": "3D-Maus in der 3D-Ansicht anzeigen", "ui.3d_mouse_show_in_view_tip": "Ein Bild Ihrer 3D-Maus unten links, das sich mit ihr bewegt.", + "ui.3d_mouse_sketch_orbit": "Skizzen mit der 3D-Maus drehen", + "ui.3d_mouse_sketch_orbit_tip": "Die 3D-Maus dreht die Ansicht auch, solange eine Skizze offen ist und frontal gesehen wird, wie es die Maus kann. Aus: Sie verschiebt und zoomt die Skizze, bis Sie die Ansicht neigen.", "ui.3d_mouse_skip": "Mein Gerät kann das nicht", "ui.3d_mouse_skip_tip": "Überspringt diese Bewegung in beide Richtungen; die Kalibrierung lässt sie aus.", "ui.3d_mouse_speed": "Geschwindigkeit der 3D-Maus", @@ -3019,6 +3492,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.", "ui.3d_mouse_switch_cap": "Als Zurück zur Mitte kalibrieren", "ui.3d_mouse_switch_trackball": "Als Trackball kalibrieren", + "ui.3d_mouse_use_direct": "Direkten Treiber verwenden", + "ui.3d_mouse_use_direct_tip": "Die 3D-Maus direkt lesen (hidraw) statt über spacenavd.", "ui.3d_mouse_view_axis": "Ansichtsachse", "ui.3d_mouse_view_off": "Aus", "ui.3d_mouse_view_off_tip": "Das Profil des 3D-Maus-Treibers schaltet diese Achse ab, daher bewegt ihre Bewegung die Ansicht nicht. Wählen Sie hier die Bewegung einer anderen Achse, werden die beiden getauscht und sie bewegt die Ansicht nicht mehr.", @@ -3134,6 +3609,8 @@ "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_right": "Rechts", "ui.agent_chat_save": "Speichern", "ui.agent_chat_send": "Senden", @@ -3285,6 +3762,7 @@ "ui.circle": "Kreis", "ui.circular_pattern": "Radiales Muster", "ui.clear": "Leeren", + "ui.clear_print_face": "Auflagefläche entfernen", "ui.clear_recent": "Zuletzt verwendet leeren", "ui.clear_the_selection": "Auswahl aufheben", "ui.click_selects_whole_bodies": "Klick wählt ganze Körper aus", @@ -3319,6 +3797,15 @@ "ui.constant": "Konstant", "ui.constraints_conflict": "Bedingungen konfliktreich", "ui.construction": "Konstruktionslinie", + "ui.construction.angle_hint": "Konstruktionslinie: Punkt anklicken, dann Winkel eingeben oder Richtung wählen", + "ui.construction.bisector_hint": "Winkelhalbierende: zwei sich schneidende Geraden wählen, dann gewünschte Lösung", + "ui.construction.common_hint": "Gemeinsame Tangente: zwei Kreise/Bögen wählen, dann gewünschte Lösung", + "ui.construction.contact_hint": "Tangente: Kreis/Bogen oder offenen Spline/Kegelschnitt wählen, dann Berührpunkt (Freiformkurven: Endpunkte)", + "ui.construction.external_hint": "Tangente: Kreis oder Bogen, äußeren Punkt, dann gewünschte Lösung wählen", + "ui.construction.free_hint": "Konstruktionslinie: zwei Punkte anklicken; Rechtsklick beginnt neu", + "ui.construction.point_hint": "Konstruktionslinie: Durchgangspunkt anklicken", + "ui.construction.reference_hint": "Konstruktionslinie: Gerade wählen, dann Punkt; Rechtsklick wechselt die Referenz", + "ui.construction.ribbon_label": "Hilfslinien", "ui.context_menu": "Kontextmenü", "ui.continuity_g0": "G0 Knick", "ui.continuity_g1": "G1 Tangente", @@ -3678,12 +4165,17 @@ "ui.plugins_skipped": "{count} Ordner konnte(n) nicht gelesen werden.", "ui.plugins_version": "{version}", "ui.point": "Punkt", + "ui.point_cloud_add_filter": "Filter hinzufügen…", "ui.point_cloud_auto_range": "Bereich automatisch", "ui.point_cloud_color_by": "Färben nach", "ui.point_cloud_file_colors": "Farben der Datei", + "ui.point_cloud_filter_by": "Filtern nach", "ui.point_cloud_height_high": "Oberste Höhe", "ui.point_cloud_height_low": "Unterste Höhe", + "ui.point_cloud_legend_filter": "In der Legende oben ein- und ausgeblendet", "ui.point_cloud_no_file_colors": "Die Datei hat keine Farben", + "ui.point_cloud_pause": "Pause", + "ui.point_cloud_play": "Abspielen", "ui.point_cloud_single_color": "Einheitliche Farbe", "ui.point_cloud_size": "Punktgröße", "ui.point_cloud_units": "Einheit der Datei", @@ -3692,6 +4184,11 @@ "ui.polygon": "Polygon", "ui.preferences": "Einstellungen", "ui.press_a_key": "Taste drücken…", + "ui.print_face_cleared": "{body}: Auflagefläche entfernt", + "ui.print_face_input": "Auflagefläche", + "ui.print_face_one_body": "Eine Auflagefläche gehört zu einem Körper: Sende diesen Körper allein, um sie zu ändern", + "ui.print_face_pick": "Eine plane Fläche eines Volumenkörpers auswählen, um sie zur Auflagefläche zu machen", + "ui.print_face_set": "{body}: Auflagefläche gesetzt; An Slicer senden legt den Körper darauf", "ui.profile": "Profil", "ui.profile_plane": "Profilebene", "ui.profiles": "Profile", @@ -3752,6 +4249,7 @@ "ui.revolve": "Drehen", "ui.ribbon_exit": "Beenden", "ui.ribbon_labels": "Text unter den Symbolen im Menüband anzeigen", + "ui.ribbon_menus_skip_pinned": "Angeheftete Befehle nicht in den Aufklappmenüs wiederholen", "ui.ribbon_pin_footer": "Angeheftete Befehle erscheinen im Menüband. Fahren Sie über eine Zeile, um sie anzuheften oder zu lösen.", "ui.ribbon_workspace": "Arbeitsbereich", "ui.right": "Rechts", @@ -3769,6 +4267,7 @@ "ui.selectOther": "Andere auswählen…", "ui.selectTangentChain": "Tangentialkette auswählen", "ui.select_all": "Alles auswählen", + "ui.select_components_by_face": "Komponenten auswählen (eine Fläche wählt ihre Komponente)", "ui.selection": "Auswahl", "ui.selection_modifiers": "Auswahlmodifikatoren", "ui.selection_shift_add_only": "Umschalt+Klick fügt hinzu, ohne zu entfernen", @@ -3806,6 +4305,11 @@ "ui.sketch_intersect_selection_filter": "Auswahlfilter", "ui.sketch_modifiers": "Skizzenmodifikatoren", "ui.sketch_project_geometry": "Geometrie", + "ui.sketch_report.all_constrained": "Alles ist vollständig bestimmt.", + "ui.sketch_report.dof": "Freiheitsgrade", + "ui.sketch_report.loose": "Nicht vollständig bestimmt", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Skizzenbericht", "ui.sketch_scale": "Skizzenskalierung", "ui.sketches": "Skizzen", "ui.slice": "Scheibe", @@ -3897,10 +4401,19 @@ "ui.view_centre": "Ansichtszentrum", "ui.viewport_status_clear": "{name} ausschalten", "ui.viewport_status_clear_hint": "{name} ausschalten · Esc", + "ui.viewport_status_edges": "{count} Kanten", + "ui.viewport_status_from": "aus {direction}", "ui.viewport_status_key": "Legende für {name} anzeigen", "ui.viewport_status_minimum_radius": "Mindestradius", "ui.viewport_status_pairs": "{count} Paare", + "ui.viewport_status_section_hatch": "{setting} · Schraffur", + "ui.viewport_status_section_plane": "Aktuelle Ebene", + "ui.viewport_status_section_tilted": "geneigt · {offset}", "ui.viewport_status_stripes": "{count} Streifen", + "ui.viewport_status_summary_clear": "Alles ausschalten", + "ui.viewport_status_summary_heading": "In dieser Ansicht", + "ui.viewport_status_summary_label": "{name} und {count} weitere Elemente", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Dem Design folgen", "ui.visual_shaded_hidden_edges": "Schattiert mit verdeckten Kanten", "ui.visual_shaded_with_edges": "Schattiert mit Kanten", @@ -3933,7 +4446,9 @@ "update.button": "Update", "update.button_version": "Auf {version} aktualisieren", "update.check_automatically": "Automatisch nach Updates suchen", + "update.check_for_updates": "Nach Updates suchen", "update.check_now": "Jetzt suchen", + "update.check_unavailable": "Für diese Installation ist keine Update-Prüfung verfügbar.", "update.checking": "Suche läuft…", "update.copy": "Kopieren", "update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.", diff --git a/assets/locales/en-GB.json b/assets/locales/en-GB.json index 5e8a30878f..2d254b4de5 100644 --- a/assets/locales/en-GB.json +++ b/assets/locales/en-GB.json @@ -1,22 +1,42 @@ { + "A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges", "Base Center": "Base Centre", "Center X": "Centre X", "Center Y": "Centre Y", "Center of mass": "Centre of mass", + "Centre to centre": "Centre to centre", "Circle center": "Circle centre", + "Colour": "Colour", + "Copy all licences": "Copy all licences", "Customize Toolbar": "Customise Toolbar", "Customize…": "Customise…", "Face center": "Face centre", "Favorites": "Favourites", + "Favourite": "Favourite", "Mass Center": "Mass Centre", "Modeled": "Modelled", + "Select the centre the pattern turns around first": "Select the centre the pattern turns around first", + "Snap to face and edge centres": "Snap to face and edge centres", + "Specialise": "Specialise", + "cmd.appearance_favorite": "Favourite Look", "cmd.component_cycle_color": "Cycle Component Colour", "cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus", "cmd.inspect_center_of_mass": "Centre of Mass", "cmd.inspect_colored_components": "Enable Component Colours", "cmd.inspect_colored_components_disable": "Disable Component Colours", + "cmd.parameters_favorite": "Favourite Parameter", "cmd.render_aging_describe": "Layered Surface Aging", "cmd.render_effects_describe": "Lens and Creative Effects", + "cmd.sketch.construction_line": "Construction Line", + "cmd.sketch.construction_line.angle": "Construction Line at Angle", + "cmd.sketch.construction_line.bisector": "Angle Bisector", + "cmd.sketch.construction_line.horizontal": "Horizontal Construction Line", + "cmd.sketch.construction_line.parallel": "Parallel Construction Line", + "cmd.sketch.construction_line.perpendicular": "Perpendicular Construction Line", + "cmd.sketch.construction_line.tangent_at": "Tangent at Curve Point", + "cmd.sketch.construction_line.tangent_external": "Tangent through External Point", + "cmd.sketch.construction_line.tangent_two": "Common Tangent", + "cmd.sketch.construction_line.vertical": "Vertical Construction Line", "cmd.sketch_arc_center_point": "Centre Point Arc", "cmd.sketch_circle_center": "Centre Diameter Circle", "cmd.sketch_rectangle_center": "Centre Rectangle", @@ -28,7 +48,26 @@ "color.gamma": "Showing colour values in 0-255 gamma space", "color.linear": "Showing colour values in 0-1 linear space", "color.selected": "Selected colour", + "dxf.cancelled": "DXF export cancelled", + "dxf.cancelling": "Cancelling DXF export", + "help.apache": "Apache licence 2.0", + "help.appearance.assign": "Gives the selected bodies, faces or components a colour or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.", + "help.face.delete": "Removes the selected faces and lets the neighbouring 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.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; 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.licences": "Licences", + "help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.", + "help.mit": "MIT licence", + "help.solid.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on moulded or cast parts so that they slide out of the mould.", + "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 centre line.", + "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 coloured 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, colour or offset.", + "help.third_party": "Third-party licences", + "home.new_mm_hint": "Millimetres · opens in Design", + "navbar.custom.customise": "Customise navigation bar…", + "navbar.custom.position_centre": "Centre", + "navbar.description.separator": "A thin line that groups neighbouring slots.", "pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.", + "prefs.bottom_centre": "Bottom centre", "prefs.logo_theme": "Theme's colours", "prefs.max_file_size": "Largest file to open", "prefs.max_file_size_design": "Design files", @@ -39,6 +78,7 @@ "prefs.megabytes": "MB", "prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour", "prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.", + "prefs.sketch_construction_lines": "Extended construction line tools", "prefs.sketch_recent_snap": "Auto snap to recent centres and midpoints", "prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centres or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.", "prefs.sketch_state_icons": "Sketch icons in the colour of their state", @@ -52,6 +92,7 @@ "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.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", "render.aging_add": "Add aging layer", @@ -81,6 +122,7 @@ "render.aging_target_help": "A named target must still match its captured index and name. Global masks are world-anchored; they do not follow moving parts. This is neither cavity detection nor true edge wear. Filter the list to reach additional bodies.", "render.aging_target_search": "Filter body names", "render.aging_upward": "Upward-facing deposition", + "render.appearance_editor.base_colour": "Base colour", "render.backdrop_first": "Start / centre colour", "render.backdrop_second": "End / edge colour", "render.backdrop_x": "Centre X", @@ -97,21 +139,31 @@ "render.base_color": "Base colour · sRGB", "render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalised box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.", "render.chromatic_offset": "Chromatic edge offset", + "render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points towards the emitting side. This differs from GPU half-edge vectors.", "render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colours are linear RGB and intensity is relative, not watts.", "render.cpu_linear_color": "Linear colour (R, G, B)", "render.effects_gpu_only": "These effects require GPU path tracing. Changing the page never switches the renderer automatically.", "render.effects_help": "Combine ordered colour looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.", "render.effects_reset_lens": "Reset lens effects", "render.effects_seed": "Pattern seed", + "render.env_colour": "Colour", + "render.environment.radial_only": "Centre and radius apply to Radial mode.", + "render.error_finish_gesture": "finish the modelling gesture before previewing", + "render.error_finish_operation": "finish the current modelling operation before entering Render", "render.film_grain": "Photographic grain", "render.flare_ghosts": "Ghost rings per source", "render.flare_halo": "Flare halo", "render.flare_streak": "Anamorphic-style streak", "render.flare_strength": "Highlight flare strength", "render.flare_threshold": "Scene-linear flare threshold", + "render.frame_centre_mark": "Centre mark", "render.hdr_primaries_declared": "The source declares its colour primaries.", "render.hdr_primaries_srgb": "Colour primaries: sRGB/D65", "render.hdr_primaries_undeclared": "The source does not declare primaries; review the renderer colour requirements.", + "render.image_cancelled": "Render cancelled", + "render.image_cancelling": "Cancelling", + "render.image_scene_stale": "Finish the modelling gesture or refresh the scene before checking or starting an image.", + "render.inspector_colour": "Colour", "render.layer_down": "Move down", "render.layer_enabled": "Layer enabled", "render.layer_remove": "Remove layer", @@ -126,6 +178,11 @@ "render.library_rig_help": "Fit once at an explicit centre and physical extent. Complete rigs include passive cards; calibration blockers still refuse. Conservative emitter/card clearance is checked before saving.", "render.library_source_preview_help": "Authored base-colour source map. This is not a rendered or measured material preview.", "render.library_unfavorite": "Remove favourite", + "render.light_behaviour": "BEHAVIOUR", + "render.light_preview_help": "Live view is unshadowed; Area lights use a centre-sample approximation. Render uses the complete light model.", + "render.light_product_centre": "Product centre", + "render.light_temperature": "Colour temperature", + "render.lighting_colour": "Colour", "render.look_add": "Add colour look", "render.look_bleach": "Bleach-bypass-inspired", "render.look_cool": "Cool", @@ -149,30 +206,45 @@ "render.radial_distortion": "Radial distortion", "render.ribbon_aging": "Aging", "render.ribbon_effects": "Effects", + "render.ribbon_studios_help": "Browse implemented studio compositions and review their replacements before applying. Concept-only scenes remain labelled.", "render.sharpening": "Detail sharpening", "render.soft_focus": "Soft focus", + "render.stage_panels.centre": "Centre", + "render.stage_panels.source_note": "Authored base-colour source map. Not a measured material.", + "render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.", "render.studio_center": "Centre X, Y, Z (mm)", "render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.", "sketch_text.align_center": "Centre", "ui.3d_mouse_kind_cap": "Return to Centre", "ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.", + "ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre", "ui.3d_mouse_step_pull": "Pull the cap towards you, hold it, then let go.", "ui.3d_mouse_switch_cap": "Calibrate as Return to Centre", + "ui.agent_chat_minimize": "Minimise to a strip", + "ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.", "ui.center": "Centre", "ui.center_arc_follows_cursor": "Centre point arc follows the cursor's direction", "ui.center_arc_follows_cursor_tip": "A centre point arc runs clockwise or counter-clockwise the way the cursor goes round its centre. Off: always counter-clockwise from the start.", "ui.centerline": "Centreline", + "ui.colour_vision": "Colour vision", + "ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.", + "ui.colour_vision_off_tip": "The theme's own colours", + "ui.construction.ribbon_label": "X-Lines", "ui.customizeToolbar": "Customise Toolbar…", + "ui.default_modelling_orientation": "Default modelling orientation", "ui.design_properties.license": "Licence", "ui.design_properties.organization": "Organisation", + "ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file", "ui.layout_compact": "Compact", "ui.layout_floating": "Floating", + "ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.", "ui.point_cloud_color_by": "Colour By", "ui.point_cloud_file_colors": "File Colours", "ui.point_cloud_no_file_colors": "The file has no colours", "ui.point_cloud_single_color": "Single Colour", "ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)", "ui.ribbon_labels": "Show text under ribbon icons", + "ui.view_centre": "View centre", "ui.workspace_layout": "Workspace layout", "ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)", "update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check." diff --git a/assets/locales/en.json b/assets/locales/en.json index 9e24427c27..97b51417a2 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -6,7 +6,7 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.", "2D": "2D", "3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.", - "A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges", + "A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centers of its round edges", "A new configuration, a copy of the active one": "A new configuration, a copy of the active one", "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out", "A short title for the problem (optional)": "A short title for the problem (optional)", @@ -60,6 +60,7 @@ "BLEND SURFACE": "BLEND SURFACE", "BOUNDARY SURFACE": "BOUNDARY SURFACE", "BROWSER": "BROWSER", + "Back to the original order": "Back to the original order", "Ball": "Ball", "Bar": "Bar", "Base Center": "Base Center", @@ -100,7 +101,7 @@ "Center X": "Center X", "Center Y": "Center Y", "Center of mass": "Center of mass", - "Centre to centre": "Centre to centre", + "Centre to centre": "Center to center", "Centroid": "Centroid", "Chamfer Type": "Chamfer Type", "Channel": "Channel", @@ -117,7 +118,7 @@ "Close draft": "Close draft", "Collapse": "Collapse", "Collisions": "Collisions", - "Colour": "Colour", + "Colour": "Color", "Command": "Command", "Comment": "Comment", "Component 1": "Component 1", @@ -137,7 +138,7 @@ "Copies the definition only. Assign it from Physical Material when ready.": "Copies the definition only. Assign it from Physical Material when ready.", "Copy Error Message": "Copy Error Message", "Copy Warning Message": "Copy Warning Message", - "Copy all licences": "Copy all licences", + "Copy all licences": "Copy all licenses", "Copy diagnostic information": "Copy diagnostic information", "Copy report": "Copy report", "Copy screenshot": "Copy screenshot", @@ -244,7 +245,7 @@ "Factor must be a positive number": "Factor must be a positive number", "Fastener": "Fastener", "Favorites": "Favorites", - "Favourite": "Favourite", + "Favourite": "Favorite", "Feature": "Feature", "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Files from many CAD programs have Y up, so they open lying on their back here (Z up).", "Filter": "Filter", @@ -563,7 +564,7 @@ "Select bodies or faces instead": "Select bodies or faces instead", "Select edges": "Select edges", "Select points of the sketch being edited": "Select points of the sketch being edited", - "Select the centre the pattern turns around first": "Select the centre the pattern turns around first", + "Select the centre the pattern turns around first": "Select the center the pattern turns around first", "Select the curves to offset first": "Select the curves to offset first", "Select the mirror line": "Select the mirror line", "Select the sketch curves or points to mirror first": "Select the sketch curves or points to mirror first", @@ -592,12 +593,14 @@ "Sketch points": "Sketch points", "Slider": "Slider", "Slot width": "Slot width", - "Snap to face and edge centres": "Snap to face and edge centres", + "Snap to face and edge centres": "Snap to face and edge centers", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft closed before these designs were saved. Their last autosave can be opened.", + "Sort A to Z": "Sort A to Z", + "Sort Z to A": "Sort Z to A", "Spacing 2 must be a non-zero number": "Spacing 2 must be a non-zero number", "Spacing must be a non-zero number": "Spacing must be a non-zero number", - "Specialise": "Specialise", + "Specialise": "Specialize", "Speed": "Speed", "Splitting tool": "Splitting tool", "Square": "Square", @@ -775,6 +778,8 @@ "at the face": "at the face", "bom.group_children": "Sub-parts in their part's row", "browser.body_name": "Body{number}", + "browser.calibrate_click_a": "Click the image for Point A, or type its coordinates.", + "browser.calibrate_click_b": "Click the image for Point B, or type its coordinates.", "browser.calibrate_title": "Calibrate: {name}", "browser.capture_many": "{count} components were moved but their positions aren't captured.", "browser.capture_note": "Capture keeps the new position; Revert puts it back.", @@ -809,7 +814,7 @@ "cmd.appearance_assign": "Appearance", "cmd.appearance_chain": "Appearance Chain", "cmd.appearance_copy": "Copy Look", - "cmd.appearance_favorite": "Favourite Look", + "cmd.appearance_favorite": "Favorite Look", "cmd.appearance_library": "Appearance Library", "cmd.appearance_list": "List Appearances", "cmd.appearance_paint": "Paint Appearance", @@ -817,6 +822,7 @@ "cmd.appearance_pick_up": "Pick Up Appearance", "cmd.appearance_replace": "Replace Appearance", "cmd.assembly_bom": "Bill of Materials", + "cmd.body.print_face": "Print Face", "cmd.body_rename": "Rename Body", "cmd.browser_collapse_all": "Collapse All", "cmd.browser_expand_all": "Expand All", @@ -1003,7 +1009,7 @@ "cmd.parameters_complete": "Complete Expression", "cmd.parameters_delete": "Delete Parameter", "cmd.parameters_export": "Export Parameters", - "cmd.parameters_favorite": "Favourite Parameter", + "cmd.parameters_favorite": "Favorite Parameter", "cmd.parameters_graph": "Parameter Graph", "cmd.parameters_import": "Import Parameters", "cmd.parameters_list": "List Parameters", @@ -1101,6 +1107,16 @@ "cmd.sheet_refold": "Refold", "cmd.sheet_rules": "List Sheet Metal Rules", "cmd.sheet_unfold": "Unfold", + "cmd.sketch.construction_line": "Construction Line", + "cmd.sketch.construction_line.angle": "Construction Line at Angle", + "cmd.sketch.construction_line.bisector": "Angle Bisector", + "cmd.sketch.construction_line.horizontal": "Horizontal Construction Line", + "cmd.sketch.construction_line.parallel": "Parallel Construction Line", + "cmd.sketch.construction_line.perpendicular": "Perpendicular Construction Line", + "cmd.sketch.construction_line.tangent_at": "Tangent at Curve Point", + "cmd.sketch.construction_line.tangent_external": "Tangent through External Point", + "cmd.sketch.construction_line.tangent_two": "Common Tangent", + "cmd.sketch.construction_line.vertical": "Vertical Construction Line", "cmd.sketch_arc3d": "3D Arc", "cmd.sketch_arc_center_point": "Center Point Arc", "cmd.sketch_arc_tangent": "Tangent Arc", @@ -1340,8 +1356,8 @@ "drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further", "drive.unconnected": "Moves nothing: {part} is not connected to anything grounded", "dxf.cancel_wait": "Waiting for the export to stop…", - "dxf.cancelled": "DXF export cancelled", - "dxf.cancelling": "Cancelling DXF export", + "dxf.cancelled": "DXF export canceled", + "dxf.cancelling": "Canceling DXF export", "dxf.exporting": "Exporting DXF", "dxf.invalid_tolerance": "Curve tolerance must be between 0.0001 and 1 mm.", "dxf.progress.index": "Preparing surfaces (2/5)", @@ -1372,28 +1388,39 @@ "fits the face, e.g. M8": "fits the face, e.g. M8", "from the profile": "from the profile", "from the rule": "from the rule", - "help.apache": "Apache licence 2.0", - "help.appearance.assign": "Gives the selected bodies, faces or components a colour or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.", + "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.attributions": "Attributions (ATTRIBUTION.md)", "help.build": "Version {version} · commit {commit} · built {date}", "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.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.face.delete": "Removes the selected faces and lets the neighbouring 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.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; 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.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.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.licences": "Licences", - "help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.", - "help.mit": "MIT licence", + "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.mit": "MIT license", "help.notice": "NOTICE", "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.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.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.", + "help.sketch.construction_line.horizontal": "Create an infinite horizontal construction line through a point. Its horizontal constraint follows later edits.", + "help.sketch.construction_line.parallel": "Pick a straight sketch line, then place a parallel infinite construction line. Right-click to choose another reference.", + "help.sketch.construction_line.perpendicular": "Pick a straight sketch line, then place a perpendicular infinite construction line. Right-click to choose another reference.", + "help.sketch.construction_line.tangent_at": "Create a tangent at a point on a circle or circular arc, or an endpoint of an open spline or conic. Pick the curve, then its contact point.", + "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.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.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.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.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.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.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on moulded or cast parts so that they slide out of the mould.", + "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.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.", @@ -1406,13 +1433,13 @@ "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 centre line.", + "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.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.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 coloured 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, colour or offset.", + "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.third_party": "Third-party licences", + "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.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.", @@ -1466,7 +1493,7 @@ "home.new_inches": "New design, inches", "home.new_inches_hint": "Inches · opens in Design", "home.new_mm": "New design", - "home.new_mm_hint": "Millimetres · opens in Design", + "home.new_mm_hint": "Millimeters · opens in Design", "home.open": "Open…", "home.open_hint": "A .sc design ({shortcut})", "home.open_now": "Open now", @@ -1592,6 +1619,10 @@ "material.selection.mixed": "Different materials", "measure.at": "At", "measure.item": "Item {number}", + "measure.quick_copied": "Measurement copied", + "measure.quick_copy": "Click to copy the measurement", + "measure.quick_edges": "{count} edges", + "measure.quick_faces": "{count} faces", "messages.ago_h": "{n} h ago", "messages.ago_min": "{n} min ago", "messages.ago_s": "{n} s ago", @@ -1647,7 +1678,7 @@ "navbar.custom.buttons": "Buttons", "navbar.custom.compact": "Compact", "navbar.custom.count": "{count} of 16 slots", - "navbar.custom.customise": "Customise navigation bar…", + "navbar.custom.customise": "Customize navigation bar…", "navbar.custom.design": "Design", "navbar.custom.duplicate": "This slot is already on the bar.", "navbar.custom.edge": "Edge", @@ -1667,7 +1698,7 @@ "navbar.custom.labels": "Icons and labels", "navbar.custom.left": "Move left", "navbar.custom.position": "Position", - "navbar.custom.position_centre": "Centre", + "navbar.custom.position_centre": "Center", "navbar.custom.position_left": "Left", "navbar.custom.position_right": "Right", "navbar.custom.preview_as": "Preview as", @@ -1696,7 +1727,7 @@ "navbar.description.orbit": "Turns the view around the model.", "navbar.description.pan": "Slides the view sideways, up or down.", "navbar.description.projection": "Switches between orthographic and perspective views.", - "navbar.description.separator": "A thin line that groups neighbouring slots.", + "navbar.description.separator": "A thin line that groups neighboring slots.", "navbar.description.spin": "Keeps the view turning until you click or press a key.", "navbar.description.visual_style": "Switches how the model is drawn: shaded, wireframe or with hidden edges.", "navbar.description.zoom": "Moves the view closer to or further from the model.", @@ -1744,6 +1775,11 @@ "not a move": "not a move", "not a number": "not a number", "open a sketch first": "open a sketch first", + "parameters.apply_live": "Apply each change as you leave its field", + "parameters.apply_live_tip": "Off: changes wait until Apply or OK, then the design rebuilds once for all of them. Cancel or Escape drops them.", + "parameters.held": "{count} changes waiting for Apply", + "parameters.held_hint": "Edit expressions and units. Changes wait for Apply or OK.", + "parameters.held_one": "1 change waiting for Apply", "parts.automatic_name": "automatic", "parts.dimension.across_flats": "Across flats", "parts.dimension.bore": "Bore", @@ -1766,6 +1802,8 @@ "parts.family.set_screw": "Hex socket set screw, flat point", "parts.family.socket_head_cap_screw": "Socket head cap screw", "parts.family.washer": "Plain washer", + "parts.holes": "Holes (optional)", + "parts.placement_hint": "pick one or more holes to seat a part in each, or OK places it at the origin", "pattern.alignment_2": "Alignment 2", "pattern.alignment_hint": "Counts include the original. Center balances copies; Flip chooses the extra side for even totals.", "pattern.direction_1": "Direction 1", @@ -1815,9 +1853,11 @@ "prefs.bodies_folders_never": "Never", "prefs.bodies_folders_several": "Several bodies only", "prefs.bottom": "Bottom", - "prefs.bottom_centre": "Bottom centre", + "prefs.bottom_centre": "Bottom center", "prefs.bottom_right": "Bottom right", "prefs.browser": "Browser", + "prefs.browser_eye_left": "Eye next to the fold arrow", + "prefs.browser_eye_left_note": "Browser rows show their show/hide eye next to the fold arrow, as before, instead of in a column at the right end of the rows.", "prefs.browser_reveal_on_hover": "Reveal on hover", "prefs.browser_show_note": "Shown keeps the browser on screen. Reveal on hover keeps it out of sight until the pointer comes over its place or the thin tab at its left edge, and it stays open while the pointer is inside it or a menu from it is open.", "prefs.browser_shown": "Shown", @@ -1868,6 +1908,8 @@ "prefs.graphics_card_restart": "Takes effect after restarting SolidCraft. WGPU_ADAPTER_NAME overrides this choice.", "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", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Constraint glyphs leave the view when the sketch is zoomed out far enough that they would clutter it. Off: they stay at a fixed screen size at every zoom.", "prefs.history": "History", "prefs.history_origin_bodies": "Show origin and bodies at the top", "prefs.history_origin_bodies_note": "The History on the left or under the browser starts with the active component's Bodies folder and the Origin, with their eyes and menus as in the browser. The History at the bottom is unchanged.", @@ -1906,6 +1948,9 @@ "prefs.max_file_size_step": "STEP and import files", "prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Large icons", + "prefs.menu_list": "List", + "prefs.menus": "Menus", "prefs.minutes": "minutes", "prefs.minutes_suffix": " min", "prefs.mouse": "Mouse and trackpad", @@ -1925,10 +1970,16 @@ "prefs.origin_plane_names": "Origin plane names", "prefs.origin_plane_names_note": "Names shown in the browser, dialogs and lists. {top} is XY, {front} is XZ and {right} is YZ, with Z up. Commands, scripts and saved designs keep XY, XZ and YZ.", "prefs.overall_speed": "Overall speed", + "prefs.parameter_list_rows": "Parameter list rows", + "prefs.parameter_list_rows_tip": "Down arrow in an empty value field lists the parameters, favourites first, up to this many. Fewer keeps the list light in a large design.", + "prefs.persistent_ribbon_pressed": "Highlight active ribbon commands", + "prefs.persistent_ribbon_pressed_help": "Highlight ribbon commands while their effects are on. Turn off to keep the previous neutral appearance and command help.", "prefs.perspective_fov": "Field of view", "prefs.placement": "Placement", "prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap", "prefs.preview_choices": "Preview of your choices", + "prefs.print_face_highlight": "Print face highlight", + "prefs.print_face_highlight_tip": "How strongly the print face of a body is tinted and outlined in the view. 0 % shows no highlight.", "prefs.profile_active": "Active profile", "prefs.profile_applied": "Profile applied.", "prefs.profile_changed": "Settings changed since the profile was saved. Save keeps them in it.", @@ -1950,6 +2001,8 @@ "prefs.quality_fastest": "fastest", "prefs.quality_slowest": "slowest", "prefs.quality_smoother": "smoother", + "prefs.quick_measure": "Quick measurement in the view's corner", + "prefs.quick_measure_note": "The selection's size in the view's bottom right corner: an edge's length, or Ø and R for a round one, a face's area, a point's position, two items' distance and angle, several edges' or faces' total. A click copies it.", "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", @@ -1963,6 +2016,8 @@ "prefs.search_empty": "No matching preferences", "prefs.select": "Select", "prefs.select_one_item": "Select one item", + "prefs.select_other_hidden": "Select hidden geometry with Select Other", + "prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.", "prefs.selecting": "Selecting", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles", @@ -1974,11 +2029,14 @@ "prefs.sketch_align_points_note": "While drawing, a point lines up horizontally with one sketch point and vertically with another at the same time, with a dotted guide, and the click keeps both alignments. Off: only with the previous point, one axis at a time.", "prefs.sketch_conflict_colour": "Conflicting geometry in the conflict color", "prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict color too, not only its glyph or dimension. Off: they keep their usual colors.", + "prefs.sketch_construction_lines": "Extended construction line tools", "prefs.sketch_exit_view": "When a sketch ends", "prefs.sketch_exit_view.keep": "Keep the current view", "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_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", "prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centers or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.", "prefs.sketch_selection": "Sketch & selection", @@ -2031,20 +2089,28 @@ "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", "prefs.trackpad_zoom_modifier.cmd": "⌘", "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", - "prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behaviour.", + "prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behavior.", "prefs.trackpad_zoom_modifier.off": "Off", "prefs.translucent": "Translucent", "prefs.under_browser": "Under browser", "prefs.under_cursor": "Under cursor", "prefs.units_precision": "Units and precision", "prefs.updates": "Updates", + "prefs.value_boxes_in_view": "Show values beside their handles in the view", + "prefs.value_boxes_in_view_tip": "A command's value (Distance, Radius…) in a box next to its handle, over the model. Off: you type it in the command window, which takes the keyboard after a pick, and nothing covers the model.", "prefs.view": "View behavior", "prefs.view_confirm_corner": "OK and Cancel in the view's corner", "prefs.view_confirm_corner_note": "While a dialog or a sketch is open, a check and a cross at the top right of the view do what the dialog's OK and Cancel do, or finish and cancel the sketch. The view navigator moves down below them.", "prefs.viewport": "Viewport", + "prefs.viewport_analysis_status": "Show viewport analysis status", + "prefs.viewport_analysis_status_help": "Show active analyses above the viewport, open their legends from each item, and use Esc to turn off the newest analysis. Turn off to restore corner legends and the previous Esc behaviour.", "prefs.viewport_background": "Background", "prefs.viewport_background_colour": "Color", "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_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", "prefs.wheel_trackpad": "Wheel and trackpad", "prefs.workspace": "Workspace", @@ -2054,6 +2120,10 @@ "properties.title": "Properties: {name}", "recovery.days_ago": "{count} days ago", "recovery.detail": "{file} · {features} features · autosaved {when}", + "recovery.discard_all": "Discard all", + "recovery.discard_all_detail": "This cannot be undone.", + "recovery.discard_all_title": "Discard {count} autosaves?", + "recovery.discard_all_title_one": "Discard 1 autosave?", "recovery.hours_ago": "{count} h ago", "recovery.just_now": "just now", "recovery.min_ago": "{count} min ago", @@ -2076,13 +2146,13 @@ "render.aging_class_any": "All opaque material classes", "render.aging_class_dielectric": "Nonmetallic surfaces only", "render.aging_class_metal": "Metallic surfaces only", - "render.aging_color": "Layer colour · sRGB", + "render.aging_color": "Layer color · sRGB", "render.aging_coverage": "Coverage", "render.aging_dust": "Deposited dust", "render.aging_empty": "No aging layers: the original surfaces remain unchanged.", "render.aging_fading": "Faded coating", "render.aging_grime": "Grime & staining", - "render.aging_help": "Combine up to eight optical layers, in order. Rust and patina are authored appearances, not age/corrosion simulations. Scale is in world millimetres. No geometry, mass or engineering property is changed; glass and passive stages are excluded.", + "render.aging_help": "Combine up to eight optical layers, in order. Rust and patina are authored appearances, not age/corrosion simulations. Scale is in world millimeters. No geometry, mass or engineering property is changed; glass and passive stages are excluded.", "render.aging_metallic": "Layer metallic fraction", "render.aging_origin": "Field origin · world mm", "render.aging_patina": "Copper-green patina", @@ -2098,7 +2168,7 @@ "render.all_controls_help": "Dense inspection of all current controls. Section pages provide focused editing without changing the underlying request.", "render.all_shots_label": "All Shots", "render.appearance_editor.absorption": "Absorption", - "render.appearance_editor.base_colour": "Base colour", + "render.appearance_editor.base_colour": "Base color", "render.appearance_editor.body_reason": "Optical bindings target one whole body. Pick Body to edit these controls.", "render.appearance_editor.boundary": "Boundary", "render.appearance_editor.box": "Box", @@ -2162,7 +2232,7 @@ "render.background_not_light": "Background color is presentation, not an additional lighting source.", "render.background_regrade": "Apply to retained raw master", "render.background_stage_occlusion": "This shot includes physical stage geometry. A large floor or wall can fill the frame and hide a camera-only background. Clear or resize the stage deliberately, or change its physical reflectance. Regrading cannot remove captured geometry.", - "render.background_workflow": "Choose the background you intend: a camera-only colour/gradient, a physical stage that receives light, or HDR illumination. They are independent. Live Preview is a modelling view; render a new image or regrade a retained master to see camera finishing.", + "render.background_workflow": "Choose the background you intend: a camera-only color/gradient, a physical stage that receives light, or HDR illumination. They are independent. Live Preview is a modeling view; render a new image or regrade a retained master to see camera finishing.", "render.base_appearance": "Editable body appearance", "render.base_apply": "Apply body appearance", "render.base_apply_help": "One undoable visual assignment to the reviewed exact body. Existing face-specific looks and engineering materials remain separate.", @@ -2283,7 +2353,7 @@ "render.cpu_aa_help": "Spatial samples: more samples improve edges and increase render time.", "render.cpu_area_add": "Add rectangular CPU light", "render.cpu_area_editor": "CPU rectangular lights", - "render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points towards the emitting side. This differs from GPU half-edge vectors.", + "render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points toward the emitting side. This differs from GPU half-edge vectors.", "render.cpu_area_full_u": "Full U side · mm", "render.cpu_area_full_v": "Full V side · mm", "render.cpu_area_samples": "Area-light samples", @@ -2372,7 +2442,7 @@ "render.draft_confirm_help": "The current render draft has unapplied edits. Save them before switching, discard them explicitly, or keep editing.", "render.draft_confirm_title": "Preserve edited render settings", "render.effects_gpu_only": "These effects require GPU path tracing. Changing the page never switches the renderer automatically.", - "render.effects_help": "Combine ordered colour looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.", + "render.effects_help": "Combine ordered color looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.", "render.effects_reset_lens": "Reset lens effects", "render.effects_seed": "Pattern seed", "render.emitted_axis": "Emitted axis", @@ -2383,7 +2453,7 @@ "render.env_change": "Change", "render.env_choose_source": "Choose an environment in Studios and Environments before editing the lighting.", "render.env_clear": "Clear", - "render.env_colour": "Colour", + "render.env_colour": "Color", "render.env_environments": "Environments", "render.env_ground": "Ground", "render.env_ground_pending": "Physical ground receives shadows as a finite diffuse surface. Shadow-only catching and ground reflections are not supported yet.", @@ -2438,7 +2508,7 @@ "render.environment.primaries": "Treat unspecified primaries as linear sRGB", "render.environment.primaries_gpu": "Interpreting unspecified primaries needs GPU path tracing.", "render.environment.radial": "Radial", - "render.environment.radial_only": "Centre and radius apply to Radial mode.", + "render.environment.radial_only": "Center and radius apply to Radial mode.", "render.environment.reflectance": "Reflectance", "render.environment.remove": "Remove stage", "render.environment.remove_confirm": "Remove the saved stage?", @@ -2470,8 +2540,8 @@ "render.error_duplicate_face": "duplicate product face appearance", "render.error_export_document": "The render export document changed while choosing its destination", "render.error_face_cap": "product preview exceeds the face-appearance admission cap", - "render.error_finish_gesture": "finish the modelling gesture before previewing", - "render.error_finish_operation": "finish the current modelling operation before entering Render", + "render.error_finish_gesture": "finish the modeling gesture before previewing", + "render.error_finish_operation": "finish the current modeling operation before entering Render", "render.error_fixed_preview": "fixed studio preview requires exposure 0, environment rotation 0 and intensity 1", "render.error_geometry_size": "product geometry size overflow", "render.error_glass_recipe": "unknown glass recipe", @@ -2529,7 +2599,7 @@ "render.flare_threshold": "Scene-linear flare threshold", "render.fold_browser": "Fold Browser", "render.frame_aspect": "Aspect", - "render.frame_centre_mark": "Centre mark", + "render.frame_centre_mark": "Center mark", "render.frame_copy_limit": "No available duplicate shot name", "render.frame_copy_name": "{name} copy {number}", "render.frame_custom": "Custom", @@ -2584,8 +2654,8 @@ "render.hide_shots_label": "Hide Shots strip", "render.hover_look": "{object} · {look} · click to select", "render.image_advanced": "Advanced GPU controls", - "render.image_cancelled": "Render cancelled", - "render.image_cancelling": "Cancelling", + "render.image_cancelled": "Render canceled", + "render.image_cancelling": "Canceling", "render.image_completed": "Image ready to export", "render.image_encoding": "Encoding PNG", "render.image_export": "Export completed image", @@ -2602,7 +2672,7 @@ "render.image_reduce": "Try a smaller image or simpler scene.", "render.image_running": "Rendering image", "render.image_samples": "Samples per pixel", - "render.image_scene_stale": "Finish the modelling gesture or refresh the scene before checking or starting an image.", + "render.image_scene_stale": "Finish the modeling gesture or refresh the scene before checking or starting an image.", "render.image_stale": "This image uses the scene captured before subsequent changes.", "render.image_start": "Start Render", "render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.", @@ -2621,7 +2691,7 @@ "render.inspector_applies_to": "Applies to", "render.inspector_both_users": "Both users", "render.inspector_chain": "Where this look comes from", - "render.inspector_colour": "Colour", + "render.inspector_colour": "Color", "render.inspector_edit": "Edit", "render.inspector_finish": "Finish", "render.inspector_from_material": "From Material", @@ -2733,7 +2803,7 @@ "render.light_anchor_pending": "Changing the light coordinate frame is not supported yet.", "render.light_azimuth": "Azimuth", "render.light_backend_limit": "Choose CPU ray tracing or an explicit GPU studio to edit lights. Legacy GPU studio lights remain read-only.", - "render.light_behaviour": "BEHAVIOUR", + "render.light_behaviour": "BEHAVIOR", "render.light_default_fill": "Fill", "render.light_default_key": "Key", "render.light_default_number": "Light {number}", @@ -2749,11 +2819,11 @@ "render.light_place_highlight": "Place by highlight", "render.light_place_pending": "Highlight placement needs viewport light picking and is not supported yet.", "render.light_position": "POSITION", - "render.light_preview_help": "Live view is unshadowed; Area lights use a centre-sample approximation. Render uses the complete light model.", - "render.light_product_centre": "Product centre", + "render.light_preview_help": "Live view is unshadowed; Area lights use a center-sample approximation. Render uses the complete light model.", + "render.light_product_centre": "Product center", "render.light_size": "Size", "render.light_softness": "Softness", - "render.light_temperature": "Colour temperature", + "render.light_temperature": "Color temperature", "render.light_temperature_help": "Approximate blackbody tint; saved as linear RGB.", "render.light_type": "Type", "render.light_type_area": "Area", @@ -2767,7 +2837,7 @@ "render.lighting_area_samples": "Area samples", "render.lighting_budget": "Budget", "render.lighting_budget_help": "Counts are read-only. Add, duplicate or delete lights to change them; the method sets the limits.", - "render.lighting_colour": "Colour", + "render.lighting_colour": "Color", "render.lighting_consent": "Replace the directional lights with this preset", "render.lighting_custom": "Custom lights", "render.lighting_custom_help": "Choosing Custom turns the preset into editable lights, one row each in the tree.", @@ -2809,7 +2879,7 @@ "render.linear_help": "Keeps a 32-bit float RGBA master before denoising, exposure, tone mapping, filters and backdrop. Linear sRGB/D65; coverage-premultiplied. Additional memory is admitted explicitly. No compression or extra passes.", "render.linear_retain": "Retain raw linear EXR", "render.linear_unavailable": "The selected completed frame has no retained EXR. Enable retention before rendering; an older PNG cannot recover raw radiance.", - "render.look_add": "Add colour look", + "render.look_add": "Add color look", "render.look_applied": "Applied {name}", "render.look_bleach": "Bleach-bypass-inspired", "render.look_cool": "Cool", @@ -2919,7 +2989,9 @@ "render.output_ready": "Ready to render", "render.output_run_check": "Run check", "render.output_size_limit": "{method}: up to {dimension} px per side and {pixels} pixels. Largest fitting size: {width} × {height}.", + "render.output_size_method_profile": "To render {width} × {height}, choose {method} with the {profile} CPU resource profile, or a smaller size.", "render.output_size_ok": "Size OK · {width} × {height} px", + "render.output_size_profile": "To render {width} × {height}, choose the {profile} CPU resource profile, or a smaller size.", "render.output_swap_orientation": "Swap width and height", "render.output_tools_inspector": "Edit size and format in the Output inspector.", "render.output_view_image": "View image", @@ -3049,7 +3121,7 @@ "render.ribbon_saved_camera": "Saved Camera", "render.ribbon_shots": "Shots", "render.ribbon_studios": "Studios", - "render.ribbon_studios_help": "Browse implemented studio compositions and review their replacements before applying. Concept-only scenes remain labelled.", + "render.ribbon_studios_help": "Browse implemented studio compositions and review their replacements before applying. Concept-only scenes remain labeled.", "render.ribbon_surface_maps": "Surface Maps", "render.sampling_title": "Adaptive and transmission sampling", "render.save_continue": "Save and continue", @@ -3132,6 +3204,7 @@ "render.size_custom": "Custom…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · needs {profile}", "render.size_presets": "Size presets", "render.size_presets_pending": "Arrives with the Output editor", "render.size_preview": "Preview", @@ -3160,7 +3233,7 @@ "render.stage_panels.badge_specification": "spec only", "render.stage_panels.candidate_summary": "Candidates: {count} sourced values. Grade and finish need review.", "render.stage_panels.captured_bounds": "Use captured scene bounds", - "render.stage_panels.centre": "Centre", + "render.stage_panels.centre": "Center", "render.stage_panels.depth": "depth", "render.stage_panels.environments": "Environments", "render.stage_panels.extent": "Extent", @@ -3215,7 +3288,7 @@ "render.stage_panels.scope": "Scope", "render.stage_panels.search_studios": "Search studios", "render.stage_panels.search_surfaces": "Search surfaces and references", - "render.stage_panels.source_note": "Authored base-colour source map. Not a measured material.", + "render.stage_panels.source_note": "Authored base-color source map. Not a measured material.", "render.stage_panels.specifications": "Specifications · read-only", "render.stage_panels.studio_ack": "Confirm replacing lighting, environment, exposure and stage", "render.stage_panels.studio_note": "Click to review. Applying replaces lighting, environment and stage only after you confirm. Lights stay editable.", @@ -3245,7 +3318,7 @@ "render.studio_ambient_rgb": "Constant environment radiance (linear RGB)", "render.studio_aperture": "Aperture radius", "render.studio_area": "Area light", - "render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.", + "render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimeters.", "render.studio_center": "Center X, Y, Z (mm)", "render.studio_denoise": "Reduce noise in the finished image", "render.studio_denoise_help": "Final edge-aware filter; fine detail can soften. Glass and sharp specular surfaces bypass the filter.", @@ -3419,6 +3492,24 @@ "sheet.flange_type_contour": "Contour", "sheet.flange_type_edge": "Edge", "sheet.no_body": "there is no sheet metal body: start one with Flange > Base, or turn a plate into sheet metal with Convert to Sheet Metal (pick its large flat face)", + "sheet.personal_rules.confirm_delete": "Confirm delete", + "sheet.personal_rules.delete": "Delete", + "sheet.personal_rules.delete_prompt": "Delete this personal rule? Rules already in designs are unchanged.", + "sheet.personal_rules.full": "The personal library already contains 64 rules.", + "sheet.personal_rules.invalid_name": "Enter a short name without line breaks or control characters.", + "sheet.personal_rules.invalid_values": "Complete the rule's values: each must work out in this design, with a positive thickness and a K factor from 0 to 1.", + "sheet.personal_rules.missing": "The selected personal rule is no longer available.", + "sheet.personal_rules.name": "Rule name", + "sheet.personal_rules.name_exists": "A personal rule with this name already exists. Use a different name.", + "sheet.personal_rules.name_hint": "A unique name", + "sheet.personal_rules.overwrite": "Save changes to selected personal rule", + "sheet.personal_rules.rename": "Rename", + "sheet.personal_rules.rule": "Personal rule", + "sheet.personal_rules.save": "Save new", + "sheet.personal_rules.scope": "Saved in your profile for all designs.", + "sheet.personal_rules.select": "Select a rule", + "sheet.personal_rules.title": "Personal rules", + "sheet.personal_rules.unsupported": "This rule has values that this version can't use.", "shortcut.json_error": "parameters must be JSON: {error}", "shortcut.no_command": "no command `{id}`; the text before the JSON must be a command id", "shortcut.pin": "Pin to the top of the S box", @@ -3455,6 +3546,7 @@ "sketch_text.align_right": "Right", "sketch_text.bold": "Bold", "sketch_text.builtin_font": "{family} (built-in)", + "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.on_path": "The text follows its path: the curve places it", @@ -3585,7 +3677,7 @@ "ui.3d_mouse_offer_swap": "Swap to Hidraw (Direct Driver)", "ui.3d_mouse_orbit": "Orbit", "ui.3d_mouse_orbit_classic": "Classic", - "ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre", + "ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's center", "ui.3d_mouse_orbit_traced": "Traced", "ui.3d_mouse_reading": "Reading", "ui.3d_mouse_reset_axes": "Reset axis assignment", @@ -3595,6 +3687,8 @@ "ui.3d_mouse_section": "3D mouse", "ui.3d_mouse_show_in_view": "Show the 3D mouse in the 3D view", "ui.3d_mouse_show_in_view_tip": "A picture of your 3D mouse in the lower left corner, moving as you move it.", + "ui.3d_mouse_sketch_orbit": "Turn sketches with the 3D mouse", + "ui.3d_mouse_sketch_orbit_tip": "The 3D mouse also turns the view while a sketch is open and seen square on, as the mouse can. Off: it pans and zooms the sketch until you tilt the view.", "ui.3d_mouse_skip": "My device can't do this", "ui.3d_mouse_skip_tip": "Skips this motion both ways, and the calibration leaves it out.", "ui.3d_mouse_speed": "3D mouse speed", @@ -3623,6 +3717,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.", "ui.3d_mouse_switch_cap": "Calibrate as Return to Center", "ui.3d_mouse_switch_trackball": "Calibrate as a trackball", + "ui.3d_mouse_use_direct": "Use Direct Driver", + "ui.3d_mouse_use_direct_tip": "Read the 3D mouse directly (hidraw) instead of through spacenavd.", "ui.3d_mouse_view_axis": "View axis", "ui.3d_mouse_view_off": "Off", "ui.3d_mouse_view_off_tip": "The 3D mouse driver's profile turns this axis off, so its motion doesn't move the view. Picking another axis's motion here swaps the two, and that motion stops moving the view.", @@ -3713,7 +3809,7 @@ "ui.agent_chat_left": "Left", "ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.", "ui.agent_chat_login_found": "login found", - "ui.agent_chat_minimize": "Minimise to a strip", + "ui.agent_chat_minimize": "Minimize to a strip", "ui.agent_chat_mode_all": "All", "ui.agent_chat_mode_ask": "Ask", "ui.agent_chat_mode_edits": "Edits", @@ -3756,6 +3852,8 @@ "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)", + "ui.agent_chat_reply_cut_off": "The reply reached its length limit before it finished, so it may be incomplete. Ask the agent to go on, or for a shorter answer.", + "ui.agent_chat_reply_declined": "The model declined to answer this message.", "ui.agent_chat_retry": "Retry", "ui.agent_chat_right": "Right", "ui.agent_chat_save": "Save", @@ -3830,7 +3928,7 @@ "ui.ai_mode_ask": "Ask every time", "ui.ai_mode_ask_note": "Every change to the design, and every file action, waits for your Allow.", "ui.ai_mode_edits": "Auto-allow model edits", - "ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.", + "ui.ai_mode_edits_note": "Modeling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.", "ui.ai_mode_global": "As set above", "ui.ai_model": "Model", "ui.ai_model_hint": "qwen3:14b", @@ -3939,6 +4037,7 @@ "ui.circle": "Circle", "ui.circular_pattern": "Circular Pattern", "ui.clear": "Clear", + "ui.clear_print_face": "Clear Print Face", "ui.clear_recent": "Clear Recent", "ui.clear_the_selection": "Clear the selection", "ui.click_selects_whole_bodies": "Click selects whole bodies", @@ -3948,15 +4047,15 @@ "ui.coincident": "Coincident", "ui.collapse": "Collapse", "ui.collinear": "Collinear", - "ui.colour_vision": "Colour vision", + "ui.colour_vision": "Color vision", "ui.colour_vision_deutan": "Deutan", "ui.colour_vision_deutan_tip": "For a weak or missing green sense", "ui.colour_vision_failed": "Failed", "ui.colour_vision_fixed": "Fixed", "ui.colour_vision_mode": "Mode", - "ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.", + "ui.colour_vision_note": "Changes the colors that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows color filters, keep this Off.", "ui.colour_vision_off": "Off", - "ui.colour_vision_off_tip": "The theme's own colours", + "ui.colour_vision_off_tip": "The theme's own colors", "ui.colour_vision_projected": "Projected", "ui.colour_vision_protan": "Protan", "ui.colour_vision_protan_tip": "For a weak or missing red sense", @@ -3973,6 +4072,15 @@ "ui.constant": "Constant", "ui.constraints_conflict": "Constraints conflict", "ui.construction": "Construction", + "ui.construction.angle_hint": "Construction line: click a point, then type an angle or pick a direction", + "ui.construction.bisector_hint": "Bisector: pick two intersecting straight lines, then the desired solution", + "ui.construction.common_hint": "Common tangent: pick two circles/arcs, then the desired solution", + "ui.construction.contact_hint": "Tangent: pick a circle/arc or open spline/conic, then the contact point (free-form curves: endpoints)", + "ui.construction.external_hint": "Tangent: pick a circle or arc, an external point, then the desired solution", + "ui.construction.free_hint": "Construction line: click two points; right-click to restart", + "ui.construction.point_hint": "Construction line: click the point to pass through", + "ui.construction.reference_hint": "Construction line: pick a straight line, then a point; right-click to change the reference", + "ui.construction.ribbon_label": "X-Lines", "ui.context_menu": "Context menu", "ui.continuity_g0": "G0 crease", "ui.continuity_g1": "G1 tangent", @@ -3998,7 +4106,7 @@ "ui.cut": "Cut", "ui.cylinder": "Cylinder", "ui.dark": "Dark", - "ui.default_modelling_orientation": "Default modelling orientation", + "ui.default_modelling_orientation": "Default modeling orientation", "ui.delete": "Delete", "ui.depth": "Depth", "ui.design": "Design", @@ -4076,7 +4184,7 @@ "ui.expand": "Expand", "ui.exportDxf": "Export DXF…", "ui.export_ellipsis_keymap": "Export…", - "ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file", + "ui.export_keymap_tip": "Keys, S box favorites and the toolbar, as a file", "ui.extend": "Extend", "ui.extent_type": "Extent Type", "ui.extract_component": "Extract to File…", @@ -4261,7 +4369,7 @@ "ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.", "ui.manufacture": "Manufacture", "ui.material_review.axes": "Material-local axes; no body orientation is assigned.", - "ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.", + "ui.material_review.claims": "Sources and licenses are unverified claims. Missing values remain unknown.", "ui.material_review.compressive_strength": "Compressive strength", "ui.material_review.conditions": "Stated conditions", "ui.material_review.density": "Density", @@ -4362,12 +4470,17 @@ "ui.plugins_skipped": "{count} folder(s) could not be read.", "ui.plugins_version": "{version}", "ui.point": "Point", + "ui.point_cloud_add_filter": "Add filter…", "ui.point_cloud_auto_range": "Automatic Range", "ui.point_cloud_color_by": "Color By", "ui.point_cloud_file_colors": "File Colors", + "ui.point_cloud_filter_by": "Filter by", "ui.point_cloud_height_high": "Highest", "ui.point_cloud_height_low": "Lowest", + "ui.point_cloud_legend_filter": "Shown and hidden in the legend above", "ui.point_cloud_no_file_colors": "The file has no colors", + "ui.point_cloud_pause": "Pause", + "ui.point_cloud_play": "Play", "ui.point_cloud_single_color": "Single Color", "ui.point_cloud_size": "Point Size", "ui.point_cloud_units": "File Units", @@ -4376,6 +4489,11 @@ "ui.polygon": "Polygon", "ui.preferences": "Preferences", "ui.press_a_key": "press a key…", + "ui.print_face_cleared": "{body}: print face cleared", + "ui.print_face_input": "Print face", + "ui.print_face_one_body": "A print face is picked for one body: send that body on its own to change it", + "ui.print_face_pick": "Select one flat face of a solid body to make it the print face", + "ui.print_face_set": "{body}: print face set; Send to Slicer lays the body on it", "ui.profile": "Profile", "ui.profile_plane": "Profile Plane", "ui.profiles": "Profiles", @@ -4439,6 +4557,7 @@ "ui.revolve": "Revolve", "ui.ribbon_exit": "Exit", "ui.ribbon_labels": "Show text under ribbon icons", + "ui.ribbon_menus_skip_pinned": "Leave pinned commands out of the drop-down menus", "ui.ribbon_pin_footer": "Pinned commands show on the ribbon. Hover a row to pin or unpin it.", "ui.ribbon_workspace": "Workspace", "ui.right": "Right", @@ -4457,6 +4576,7 @@ "ui.selectOther": "Select Other…", "ui.selectTangentChain": "Select Tangent Chain", "ui.select_all": "Select All", + "ui.select_components_by_face": "Select components (a face picks its component)", "ui.selection": "Selection", "ui.selection_modifiers": "Selection modifiers", "ui.selection_shift_add_only": "Shift+click adds without removing", @@ -4498,6 +4618,11 @@ "ui.sketch_intersect_selection_filter": "Selection Filter", "ui.sketch_modifiers": "Sketch modifiers", "ui.sketch_project_geometry": "Geometry", + "ui.sketch_report.all_constrained": "Everything is fully constrained.", + "ui.sketch_report.dof": "Degrees of freedom", + "ui.sketch_report.loose": "Not fully constrained", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Sketch Report", "ui.sketch_scale": "Sketch Scale", "ui.sketches": "Sketches", "ui.slice": "Slice", @@ -4599,13 +4724,22 @@ "ui.view_angles_readout": "Pitch {pitch} · Yaw {yaw} · Roll {roll}", "ui.view_angles_roll": "Roll", "ui.view_angles_yaw": "Yaw", - "ui.view_centre": "View centre", + "ui.view_centre": "View center", "ui.viewport_status_clear": "Turn off {name}", "ui.viewport_status_clear_hint": "Turn off {name} · Esc", + "ui.viewport_status_edges": "{count} edges", + "ui.viewport_status_from": "from {direction}", "ui.viewport_status_key": "Show key for {name}", "ui.viewport_status_minimum_radius": "Minimum radius", "ui.viewport_status_pairs": "{count} pairs", + "ui.viewport_status_section_hatch": "{setting} · hatch", + "ui.viewport_status_section_plane": "Current plane", + "ui.viewport_status_section_tilted": "tilted · {offset}", "ui.viewport_status_stripes": "{count} stripes", + "ui.viewport_status_summary_clear": "Turn everything off", + "ui.viewport_status_summary_heading": "In this view", + "ui.viewport_status_summary_label": "{name} and {count} more items", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Follow theme", "ui.visual_shaded_hidden_edges": "Shaded with hidden edges", "ui.visual_shaded_with_edges": "Shaded with edges", @@ -4636,13 +4770,15 @@ "ui.y_up": "Y up", "ui.z_up": "Z up", "ui.zoom": "Zoom", - "ui.zoom_to_cursor_tip": "The wheel zooms toward the point under the cursor (off: toward the view's centre)", + "ui.zoom_to_cursor_tip": "The wheel zooms toward the point under the cursor (off: toward the view's center)", "ui.zoom_to_the_cursor": "Zoom to the cursor", "update.available": "SolidCraft {version} is available", "update.button": "Update", "update.button_version": "Update to {version}", "update.check_automatically": "Check for updates automatically", + "update.check_for_updates": "Check for updates", "update.check_now": "Check now", + "update.check_unavailable": "Update checks are not available for this installation.", "update.checking": "Checking…", "update.copy": "Copy", "update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.", diff --git a/assets/locales/es.json b/assets/locales/es.json index c02c424f71..9112d0fa2a 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "SUPERFICIE DE TRANSICIÓN", "BOUNDARY SURFACE": "SUPERFICIE DE CONTORNO", "BROWSER": "EXPLORADOR", + "Back to the original order": "Volver al orden original", "Ball": "Rótula", "Bar": "Barra", "Base Center": "Centro de la base", @@ -181,6 +182,8 @@ "Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.", "Diameter": "Diámetro", + "Did this assembly open the wrong way up?": "¿Este ensamblaje se abrió del revés?", + "Did this part open the wrong way up?": "¿Esta pieza se abrió del revés?", "Dimension new shapes": "Acotar las formas nuevas", "Direction": "Dirección", "Discard": "Descartar", @@ -204,6 +207,7 @@ "Edges": "Aristas", "Edit": "Editar", "Edit Profile Sketch": "Editar boceto del perfil", + "Edit shortcuts": "Editar atajos", "Enable rule": "Activar regla", "Enabled": "Activado", "End Radius": "Radio final", @@ -258,6 +262,8 @@ "Flanges": "Pestañas", "Flat Pattern": "Desarrollo plano", "Flat size": "Tamaño del desarrollo", + "Flat size X": "Tamaño plano X", + "Flat size Y": "Tamaño plano Y", "Follow camera (Look At sketches)": "Seguir cámara (orientar hacia los bocetos)", "Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)", "Font": "Fuente", @@ -289,6 +295,7 @@ "Horiz./Vert.": "Horiz./Vert.", "I-Beam": "Perfil I", "IMAGES": "IMÁGENES", + "If you are offline or GitHub does not open, use Copy report and keep the text to submit later.": "Si estás sin conexión o no se abre GitHub, usa Copiar informe y guarda el texto para enviarlo luego.", "Import material into design": "Importar material al diseño", "Import…": "Importar…", "In This Design": "En este diseño", @@ -399,13 +406,16 @@ "Of": "Grado", "On your computer": "En tu equipo", "Opacity": "Opacidad", + "Open GitHub": "Abrir GitHub", "Open GitHub draft": "Abrir borrador de GitHub", + "Open GitHub issue form": "Abrir formulario de issue en GitHub", "Open bodies (no volume)": "Cuerpos abiertos (sin volumen)", "Open library...": "Abrir biblioteca…", "Open local libraries in the desktop app. You can export this design's library here.": "Abra las bibliotecas locales en la aplicación de escritorio. Aquí puede exportar la biblioteca de este diseño.", "Open surface — no volume": "Superficie abierta: sin volumen", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código abierto desarrollado en Rust. Licencia MIT O Apache-2.0.", "Opens your browser and copies the report text for you to paste into GitHub.": "Abre tu navegador y copia el texto del informe para que lo pegues en GitHub.", + "Opens your browser and copies the report text. Review and submit your report on GitHub.": "Abre el navegador y copia el texto del informe. Revisa y envía tu informe en GitHub.", "Opens your browser. Review and submit your report on GitHub.": "Abre tu navegador. Revisa y envía tu informe en GitHub.", "Operator": "Operador", "Orbit (right drag)": "Orbitar (arrastrar con el botón derecho)", @@ -460,6 +470,7 @@ "Radius": "Radio", "Radius Fit": "Ajuste de radio", "Ratio": "Relación", + "Read-only list: shortcuts can't be changed here.": "Lista de solo lectura: los atajos no se pueden cambiar aquí.", "Reattach selected face": "Volver a vincular la cara seleccionada", "Recover": "Recuperar", "Recover unsaved design?": "¿Recuperar el diseño sin guardar?", @@ -486,6 +497,7 @@ "Reopen the designs that were open when SolidCraft was last closed": "Reabrir los diseños que estaban abiertos cuando se cerró SolidCraft por última vez", "Replace existing library file": "Sustituir el archivo de biblioteca existente", "Report a bug — review a screenshot and open a GitHub draft": "Notificar un error — revisar una captura y abrir un borrador de GitHub", + "Report copied. Keep this text to submit on GitHub when you are online.": "Informe copiado. Guarda este texto para enviarlo en GitHub cuando estés en línea.", "Report text copied. Paste it into GitHub and submit your report there.": "Informe copiado. Pégalo en GitHub y envíalo allí.", "Reports are public. A GitHub account is required.": "Los informes son públicos. Se necesita una cuenta de GitHub.", "Reset Toolbar": "Restablecer barra de herramientas", @@ -547,6 +559,7 @@ "Sections (one sketch each)": "Secciones (un boceto cada una)", "Select Other": "Seleccionar otro", "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).": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todo lo que hay bajo el cursor; vuelve a pulsar en el mismo lugar para seleccionar el siguiente elemento. Haz doble clic con el botón central para volver a Inicio (Preferencias › Navegación).", + "Select Same Look": "Seleccionar mismo aspecto", "Select bodies (a face picks its body)": "Selecciona cuerpos (al seleccionar una cara se selecciona su cuerpo)", "Select bodies or faces instead": "Selecciona cuerpos o caras en su lugar", "Select edges": "Selecciona aristas", @@ -581,7 +594,10 @@ "Slider": "Deslizante", "Slot width": "Anchura de la ranura", "Snap to face and edge centres": "Ajustar a centros de caras y aristas", + "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft no puede enviar informes directamente. Envíalos en GitHub; no se necesita token en la aplicación.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.", + "Sort A to Z": "Ordenar de la A a la Z", + "Sort Z to A": "Ordenar de la Z a la A", "Spacing 2 must be a non-zero number": "El espaciado 2 debe ser un número distinto de cero", "Spacing must be a non-zero number": "El espaciado debe ser un número distinto de cero", "Specialise": "Especializar", @@ -590,6 +606,7 @@ "Square": "Cuadrado", "Stand it up": "Poner en vertical", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poner en vertical: un cuarto de vuelta alrededor del eje X, como una única operación Mover que puedes deshacer.", + "Stand this assembly up?": "¿Enderezar este ensamblaje?", "Stand this part up?": "¿Poner esta pieza en vertical?", "Start angle": "Ángulo inicial", "Start bulge": "Abombamiento inicial", @@ -640,6 +657,7 @@ "Third point": "Tercer punto", "This child is shared. Deleting it removes it from every copy of its parent.": "Este elemento secundario es compartido. Si lo eliminas, desaparecerá de todas las copias de su elemento principal.", "This child is shared. Position changes apply to every copy of its parent.": "Este elemento secundario es compartido. Los cambios de posición se aplican a todas las copias de su elemento principal.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Este componente está dentro de un subensamblaje colocado más de una vez: alinearlo lo mueve en todas las colocaciones.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Esta imagen todavía no está adjunta en GitHub. Cópiala o guárdala y adjúntala en GitHub.", "Thread depth": "Profundidad de la rosca", @@ -760,12 +778,15 @@ "at the face": "en la cara", "bom.group_children": "Subpiezas en la fila de su pieza", "browser.body_name": "Cuerpo{number}", + "browser.calibrate_click_a": "Haz clic en la imagen para el Punto A, o escribe sus coordenadas.", + "browser.calibrate_click_b": "Haz clic en la imagen para el Punto B, o escribe sus coordenadas.", "browser.calibrate_title": "Calibrar: {name}", "browser.capture_many": "Se han movido {count} componentes, pero sus posiciones no se han capturado.", "browser.capture_note": "Capturar posición conserva la nueva posición; Revertir posición restaura la anterior.", "browser.capture_one": "Se ha movido un componente, pero su posición no se ha capturado.", "browser.component_name": "Componente{number}", "browser.edit_canvas_title": "Editar lienzo: {name}", + "browser.edit_point_cloud_title": "Editar nube de puntos: {name}", "browser.move_title": "Mover: {name}", "browser.parameters": "Parámetros ({count})", "browser.precision": "Precisión: {precision}", @@ -780,18 +801,28 @@ "cam.roller": "Rodillo", "cam.roller_pick": "Rodillo (opcional)", "cam.tip_pick": "Punta (opcional)", + "camera.shot_pitch": "Inclinación", + "camera.shot_roll": "Giro", + "camera.shot_yaw": "Rumbo", + "canvas.decal_place_hint": "Haz clic en una cara para colocar la calcamonía", + "canvas.place_hint": "Haz clic en un plano o cara para colocar el lienzo", "choose a profile": "elige un perfil", "choose the cam's and the follower's joints": "elige las uniones de la leva y del seguidor", "choose the joints to couple": "elige las uniones que acoplar", "click a plane or planar face": "hacer clic en un plano o una cara plana", "click sketch curves": "hacer clic en curvas del boceto", "cmd.appearance_assign": "Apariencia", + "cmd.appearance_chain": "Cadena de aspecto", + "cmd.appearance_copy": "Copiar aspecto", + "cmd.appearance_favorite": "Aspecto favorito", "cmd.appearance_library": "Biblioteca de apariencias", "cmd.appearance_list": "Listar apariencias", "cmd.appearance_paint": "Pintar apariencia", + "cmd.appearance_paste": "Pegar aspecto", "cmd.appearance_pick_up": "Tomar apariencia", "cmd.appearance_replace": "Reemplazar apariencia", "cmd.assembly_bom": "Lista de materiales", + "cmd.body.print_face": "Cara de impresión", "cmd.body_rename": "Cambiar nombre del cuerpo", "cmd.browser_collapse_all": "Contraer todo", "cmd.browser_expand_all": "Expandir todo", @@ -799,6 +830,7 @@ "cmd.browser_move": "Mover elementos del explorador", "cmd.browser_order": "Ordenar elementos del explorador", "cmd.browser_rename_group": "Cambiar nombre del grupo", + "cmd.browser_reparent": "Mover a componente", "cmd.browser_ungroup": "Desagrupar", "cmd.browser_visibility": "Mostrar/ocultar", "cmd.canvas_calibrate": "Calibrar lienzo", @@ -815,7 +847,9 @@ "cmd.component_create": "Nuevo componente", "cmd.component_cycle_color": "Ciclar color de componente", "cmd.component_delete": "Eliminar componente", + "cmd.component_edit_in_place": "Editar en contexto", "cmd.component_extract": "Extraer a archivo", + "cmd.component_finish_edit": "Finalizar edición", "cmd.component_from_bodies": "Crear componentes a partir de cuerpos", "cmd.component_insert_design": "Insertar diseño", "cmd.component_links": "Listar vínculos", @@ -823,7 +857,9 @@ "cmd.component_metadata": "Propiedades de la pieza", "cmd.component_metadata_set": "Cambiar propiedades de la pieza", "cmd.component_move_bodies": "Mover cuerpos al componente", + "cmd.component_move_features": "Mover operaciones a componente", "cmd.component_move_sketches": "Mover bocetos al componente", + "cmd.component_paste_from_design": "Pegar desde otro diseño", "cmd.component_paste_new": "Pegar nuevo", "cmd.component_properties": "Propiedades físicas del componente", "cmd.component_relink": "Revincular origen", @@ -960,6 +996,7 @@ "cmd.motion_trace": "Trayectoria de movimiento", "cmd.motion_trace_edit": "Editar traza de movimiento", "cmd.motion_trace_keep": "Conservar trayectoria de movimiento", + "cmd.occurrence_align": "Alinear instancia", "cmd.occurrence_copy": "Pegar (instancia)", "cmd.occurrence_drag": "Arrastrar instancia", "cmd.occurrence_ground": "Fijar", @@ -990,6 +1027,10 @@ "cmd.plastic_rest": "Apoyo", "cmd.plastic_rules": "Listar reglas de plástico", "cmd.plastic_snap_fit": "Encaje a presión", + "cmd.pointcloud_delete": "Eliminar nube de puntos", + "cmd.pointcloud_edit": "Editar nube de puntos", + "cmd.pointcloud_insert": "Insertar nube de puntos", + "cmd.pointcloud_list": "Nubes de puntos", "cmd.render_aging_describe": "Envejecimiento por capas", "cmd.render_backends": "Motores de renderizado", "cmd.render_batch": "Renderizar todas las tomas", @@ -1012,15 +1053,26 @@ "cmd.render_library_surface_prepare": "Preparar mapas de superficie", "cmd.render_library_surface_unbind": "Eliminar vinculación de superficie", "cmd.render_library_targets": "Buscar destinos de recursos de renderizado", + "cmd.render_lights_add": "Añadir luz", + "cmd.render_lights_duplicate": "Duplicar luz de render", + "cmd.render_lights_list": "Listar luces de render", + "cmd.render_lights_mute": "Silenciar luz", + "cmd.render_lights_remove": "Eliminar luz de render", + "cmd.render_lights_set": "Configurar luz de render", "cmd.render_lights_solo": "Solo luz", "cmd.render_optics_bind": "Guardar asignación óptica", "cmd.render_optics_unbind": "Quitar asignación óptica", + "cmd.render_output_size": "Tamaño de salida de render", "cmd.render_preflight": "Comprobar solicitud de renderizado", + "cmd.render_preflight_copy": "Copiar informe de comprobación de render", "cmd.render_preset_apply": "Aplicar estudio nativo", "cmd.render_preset_resolve": "Revisar estudio nativo", "cmd.render_quality_prepare": "Revisar presupuesto fijo de muestras", + "cmd.render_result_close": "Cerrar imagen de render", + "cmd.render_result_copy": "Copiar imagen de render", "cmd.render_result_pin": "Conservar el resultado mostrado", "cmd.render_result_regrade": "Reeditar máster lineal", + "cmd.render_result_view": "Ver imagen de render", "cmd.render_scene": "Inspeccionar escena de renderizado", "cmd.render_settings_apply": "Aplicar cámara de renderizado", "cmd.render_settings_capture_hdr": "Guardar configuración de renderizado con HDR", @@ -1035,6 +1087,13 @@ "cmd.render_start": "Renderizar imagen", "cmd.render_status": "Progreso del renderizado", "cmd.select_clear": "Borrar selección", + "cmd.settings_profile_apply": "Aplicar perfil de ajustes", + "cmd.settings_profile_delete": "Eliminar perfil de ajustes", + "cmd.settings_profile_export": "Exportar perfil de ajustes", + "cmd.settings_profile_import": "Importar perfil de ajustes", + "cmd.settings_profile_list": "Lista de perfiles de ajustes", + "cmd.settings_profile_rename": "Renombrar perfil de ajustes", + "cmd.settings_profile_save": "Guardar perfil de ajustes", "cmd.sheet_convert": "Convertir a chapa", "cmd.sheet_export_bend_table": "Exportar tabla de plegados", "cmd.sheet_export_dxf": "Exportar desarrollo como DXF", @@ -1048,6 +1107,16 @@ "cmd.sheet_refold": "Volver a plegar", "cmd.sheet_rules": "Listar reglas de chapa", "cmd.sheet_unfold": "Desplegar", + "cmd.sketch.construction_line": "Línea de construcción", + "cmd.sketch.construction_line.angle": "Línea de construcción en ángulo", + "cmd.sketch.construction_line.bisector": "Bisectriz de ángulo", + "cmd.sketch.construction_line.horizontal": "Línea de construcción horizontal", + "cmd.sketch.construction_line.parallel": "Línea de construcción paralela", + "cmd.sketch.construction_line.perpendicular": "Línea de construcción perpendicular", + "cmd.sketch.construction_line.tangent_at": "Tangente en punto de curva", + "cmd.sketch.construction_line.tangent_external": "Tangente por punto externo", + "cmd.sketch.construction_line.tangent_two": "Tangente común", + "cmd.sketch.construction_line.vertical": "Línea de construcción vertical", "cmd.sketch_arc3d": "Arco 3D", "cmd.sketch_arc_center_point": "Arco de centro y punto", "cmd.sketch_arc_tangent": "Arco tangente", @@ -1080,6 +1149,8 @@ "cmd.sketch_drawing_bounds": "Tamaño del dibujo", "cmd.sketch_edit": "Editar boceto", "cmd.sketch_edit_text": "Editar texto", + "cmd.sketch_equation_curve": "Curva ecuación", + "cmd.sketch_equation_curve_edit": "Editar curva ecuación", "cmd.sketch_export_dxf": "Guardar como DXF", "cmd.sketch_finish_auto_constrain": "Finalizar con restricciones automáticas", "cmd.sketch_fit_curves_to_section": "Ajustar curvas a sección de malla", @@ -1165,13 +1236,20 @@ "cmd.timeline_export_script": "Exportar script de comandos", "cmd.timeline_inspect": "Inspeccionar operación", "cmd.timeline_paste": "Pegar operaciones", + "cmd.timeline_play": "Reproducir historial", "cmd.timeline_redefine": "Editar operación", "cmd.timeline_rename": "Cambiar nombre", "cmd.timeline_reorder": "Reordenar operación", "cmd.timeline_roll_to": "Desplazar marcador de la línea de tiempo", "cmd.timeline_rollback": "Mover marcador de la línea de tiempo", + "cmd.timeline_stop": "Detener historial", "cmd.timeline_suppress": "Suprimir operación", + "cmd.view.render_browser": "Explorador de render", + "cmd.view.render_dock": "Mover acople de render", + "cmd.view.render_focus": "Vista de foco de render", + "cmd.view.status_items": "Elementos de estado del visor", "cmd.view_ambient_occlusion": "Oclusión ambiental", + "cmd.view_angle_readout": "Ángulos de vista", "cmd.view_apply": "Restaurar vista guardada", "cmd.view_back": "Vista posterior", "cmd.view_bottom": "Vista inferior", @@ -1191,6 +1269,7 @@ "cmd.view_normal_to": "Normal a la selección", "cmd.view_previous_tab": "Pestaña anterior", "cmd.view_rename": "Cambiar nombre de vista guardada", + "cmd.view_render_frame": "Guías de encuadre de render", "cmd.view_right": "Vista derecha", "cmd.view_save": "Nueva vista guardada", "cmd.view_shots_strip": "Tira de tomas", @@ -1201,10 +1280,14 @@ "color.copy": "Haz clic para copiar los valores del color", "color.edit": "Haz clic para editar el color", "color.gamma": "Mostrando los valores de color en espacio gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Color en notación hexadecimal (#RRGGBB o #RRGGBBAA).", + "color.hex_hint": "#rrggbb", "color.hue": "Tono", "color.linear": "Mostrando los valores de color en espacio lineal 0-1", "color.saturation_value": "Saturación y valor", "color.selected": "Color seleccionado", + "config.column_filter_empty": "Sin columnas coincidentes", "contact.components_count": "{count} componentes", "contact.enabled_label": "activado", "coupling.gear_1": "Engranaje 1", @@ -1249,6 +1332,7 @@ "dialog.dimension_insert_hint": "Haga doble clic en otra cota de control para insertar su nombre", "dialog.dimension_name": "Cota: {name}", "dialog.edit_title": "EDITAR {title}", + "dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo", "dialog.select": "↖ Seleccionar", "dialog.selected_count": "↖ {count} seleccionados", "dialog.selection_guidance": "Selecciona una entrada para cambiarla. Haz clic en los objetos seleccionados para quitarlos; Ctrl/Cmd+clic también alterna su selección. Revisa la vista previa y pulsa Aceptar.", @@ -1308,6 +1392,7 @@ "help.appearance.assign": "Da a los cuerpos, caras o componentes seleccionados un color o un aspecto de material, con", "help.attributions": "Atribuciones (ATTRIBUTION.md)", "help.build": "Versión {version} · commit {commit} · compilada el {date}", + "help.component.finish_edit": "Guarda la pieza que estás editando en contexto, vuelve a su ensamblaje y actualiza el", "help.connect_note": "Reinicia cada asistente para que cargue el servidor de SolidCraft. Cada archivo se guardó como .bak antes de modificarlo.", "help.connect_title": "Conectar asistentes de IA", "help.face.delete": "Elimina las caras seleccionadas y deja que las caras vecinas cierren el hueco, o deja", @@ -1317,7 +1402,21 @@ "help.material.assign": "Define de qué están hechos los cuerpos seleccionados, como acero, aluminio o ABS. Usa", "help.mit": "Licencia MIT", "help.notice": "AVISO", + "help.occurrence.move": "Mueve una pieza de ensamblaje solo por las libertades permitidas por sus uniones. Selecciona una pieza", "help.parameters.change": "Abre la tabla de valores y expresiones con nombre que controlan las dimensiones del", + "help.pointcloud.insert": "Inserta un escaneo PLY, XYZ o PTS como nube de puntos junto al modelo. Úsala como", + "help.sketch.construction_line": "Crea una línea de construcción infinita a través de dos puntos. Úsala para organizar un boceto.", + "help.sketch.construction_line.angle": "Crea una línea de construcción infinita en un ángulo desde el eje X del boceto. Elige el punto a", + "help.sketch.construction_line.bisector": "Elige dos líneas rectas de boceto que se intersecten, luego elige una de las dos bisectrices del ángulo.", + "help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita a través de un punto. Su dirección horizontal", + "help.sketch.construction_line.parallel": "Elige una línea recta de boceto, luego coloca una línea de construcción paralela infinita. R", + "help.sketch.construction_line.perpendicular": "Elige una línea recta de boceto, luego coloca una línea de construcción perpendicular infinita.", + "help.sketch.construction_line.tangent_at": "Crea una tangente en un punto de un círculo o arco circular, o en un extremo de una curva abierta.", + "help.sketch.construction_line.tangent_external": "Elige un círculo o arco circular, luego un punto externo y una solución de tangente. Th", + "help.sketch.construction_line.tangent_two": "Elige dos círculos o arcos circulares, luego elige una tangente común en la previsualización.", + "help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita a través de un punto. Su dirección vertical", + "help.sketch.equation_curve": "Dibuja una curva a partir de fórmulas: x(t) e y(t), o y = f(x), sobre un rango. La fórmula", + "help.sketch.inspect": "Abre un informe sobre el boceto abierto: cuántos grados de libertad quedan y qué", "help.solid.align": "Mueve los cuerpos seleccionados para que un punto, cara o plano de ellos coincida con un punto,", "help.solid.chamfer": "Corta las aristas seleccionadas con un chaflán plano de la distancia que indiques. Úsalo para romper aristas vivas o para facilitar la entrada de un pasador o un tornillo.", "help.solid.combine": "Combina cuerpos: los une, los recorta de un cuerpo de destino o conserva solo el volumen que comparten, y puede mantener los cuerpos de herramienta. Úsalo para fusionar piezas, recortar una forma con otra o encontrar dónde se solapan dos cuerpos.", @@ -1343,6 +1442,9 @@ "help.third_party": "Licencias de terceros", "help.timeline.compute_all": "Reconstruye cada operación de la línea de tiempo desde el inicio. Úsalo para refrescar el modelo", "help.timeline.delete": "Elimina las operaciones seleccionadas de la línea de tiempo; borrar un boceto también elimina", + "help.timeline.play": "Reproduce las operaciones restantes una a una, con una pausa de medio segundo tras cada una", + "history.sketch_under_constrained": "Bajo restricción ({count} grados de libertad)", + "history.sketch_under_constrained_one": "Bajo restricción ({count} grado de libertad)", "hole_presets.confirm_delete": "Confirmar eliminación", "hole_presets.delete": "Eliminar", "hole_presets.delete_prompt": "Eliminar esta preconfiguración personal? Los agujeros existentes no cambian.", @@ -1443,9 +1545,26 @@ "joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.", "joint.rigid_group_one_component": "Estos {count} cuerpos están todos en un componente (o en el propio diseño): usa antes {command}.", "joint.rigid_group_select_components": "Selecciona dos o más componentes.", + "joint_move.blocked_hint": "Comprueba el tipo de unión (como Rígida), anclaje, límites de la unión y accionamientos por parámetro", + "joint_move.blocked_title": "No se puede mover esta pieza", + "joint_move.coarse": "Paso de rotación cerca del eje (°)", + "joint_move.distance": "Distancia desde el inicio", + "joint_move.done": "Listo", + "joint_move.fine": "Paso de rotación lejos del eje (°)", + "joint_move.fixed": "Esta pieza no tiene eje de unión móvil.", + "joint_move.help": "Mover — arrastra una flecha o anillo. Esc cancela el arrastre actual.", + "joint_move.legacy_hint": "Desactiva para usar el panel Mover anterior.", + "joint_move.precision": "Tira fuera de la guía discontinua para rotación fina.", + "joint_move.rotation": "Rotación desde el inicio", + "joint_move.select": "Selecciona una cara o una pieza de ensamblaje, luego elige Mover.", + "joint_move.settings": "Mover ensamblaje", + "joint_move.step": "Paso", + "joint_move.use_handles": "Usar mangos de movimiento de unión para Mover ensamblaje", "keymap.command_id": "ID del comando", "keymap.empty": "No hay comandos coincidentes", "keymap.use_anyway": "Usar {key} aquí de todos modos", + "label.move_to_left_side": "Mover a la izquierda", + "label.move_to_right_side": "Mover a la derecha", "manage.appearance_hint": "Elija una apariencia para la selección o arrastre una muestra a un cuerpo o una cara.", "manage.assign_hint": "Seleccione cuerpos y elija su regla de diseño de piezas de plástico.", "manage.config_hint": "Edite las filas, columnas y reglas de configuración y luego active una fila o muestre su vista previa.", @@ -1500,6 +1619,10 @@ "material.selection.mixed": "Materiales distintos", "measure.at": "En", "measure.item": "Elemento {number}", + "measure.quick_copied": "Medición copiada", + "measure.quick_copy": "Haz clic para copiar la medición", + "measure.quick_edges": "{count} aristas", + "measure.quick_faces": "{count} caras", "messages.ago_h": "hace {n} h", "messages.ago_min": "hace {n} min", "messages.ago_s": "hace {n} s", @@ -1530,9 +1653,13 @@ "nav.free_orbit_upright.help": "Arrastrar el ratón de lado gira el modelo alrededor del eje vertical del mundo; arriba y abajo", "nav.free_orbit_upright.label": "Girar alrededor del eje vertical", "nav.fusion.help": "Arrastrar con el botón central desplaza, Mayús+central orbita y arrastrar con el derecho orbita", + "nav.hfss.help": "Alt+arrastre izq. desplaza, Mayús+Alt+arrastre izq. hace zoom, Ctrl+arrastre izq. en espacio vacío orbita", "nav.inventor.help": "Arrastrar con el botón central desplaza, Mayús+central orbita, Ctrl+central hace zoom y arrastrar con el derecho orbita", + "nav.maya.help": "Alt+arrastre izq. orbita, Alt+arrastre central desplaza, Alt+arrastre der. hace zoom; arrastre central también desplaza", "nav.openscad.help": "Arrastrar con el botón izquierdo sobre un espacio vacío orbita, Mayús+izquierdo selecciona con un recuadro y arrastrar geometría del boceto la edita; arrastrar con el derecho o el central desplaza, y Mayús+derecho o Mayús+central hace zoom", "nav.perspective.sketch": "La edición de bocetos usa vista ortográfica. Termina el boceto para cambiar a perspectiva.", + "nav.right_drag_orbits.help": "Desactivado: un arrastre derecho deja la cámara sola y el botón derecho solo abre el menú contextual", + "nav.right_drag_orbits.label": "Arrastre derecho orbita", "nav.solidworks.help": "Arrastrar con el botón central orbita, Ctrl+central desplaza, Mayús+central hace zoom y arrastrar con el derecho orbita", "nav.tinkercad.help": "Arrastrar con el botón derecho orbita; con el central o con Mayús+derecho desplaza", "nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.", @@ -1541,9 +1668,96 @@ "nav.touchpad.pan": "Desplazar (mantén Mayús y mueve el puntero; sin pulsar botones)", "nav.touchpad.scroll": "El panel táctil usa el desplazamiento vertical para hacer zoom. La opción de desplazamiento con dos dedos se conserva para otros ajustes predefinidos.", "nav.touchpad.zoom": "Zoom (desplazamiento vertical o gesto de pellizco)", + "navbar.agent_chat": "Panel de chat del Asistente", + "navbar.agent_cursor": "Mostrar cursor del agente", + "navbar.ambient_occlusion": "Oclusión ambiental", + "navbar.assistant": "Asistente", + "navbar.custom.add_slot": "Añadir {slot}", + "navbar.custom.always": "Siempre visible", + "navbar.custom.available": "{available} · arrastra a la barra, o pulsa Añadir", + "navbar.custom.buttons": "Botones", + "navbar.custom.compact": "Compacto", + "navbar.custom.count": "{count} de 16 huecos", + "navbar.custom.customise": "Personalizar barra de navegación…", + "navbar.custom.design": "Diseño", + "navbar.custom.duplicate": "Este hueco ya está en la barra.", "navbar.custom.edge": "Borde", "navbar.custom.edge_bottom": "Abajo", "navbar.custom.edge_top": "Arriba", + "navbar.custom.fade": "Desvanecer", + "navbar.custom.full": "Quita un hueco para añadir otro.", + "navbar.custom.hidden_design": "Oculto en Diseño", + "navbar.custom.hidden_render": "Oculto en Render", + "navbar.custom.hide": "Ocultar barra de navegación", + "navbar.custom.hide_idle": "Ocultar hasta pasar el ratón", + "navbar.custom.icon_only": "Solo icono", + "navbar.custom.icon_value": "Icono y valor", + "navbar.custom.icons": "Iconos", + "navbar.custom.idle": "En reposo", + "navbar.custom.intro": "Elige los controles en la barra en la parte inferior de la vista 3D, y en qué orden.", + "navbar.custom.labels": "Iconos y etiquetas", + "navbar.custom.left": "Mover a la izquierda", + "navbar.custom.position": "Posición", + "navbar.custom.position_centre": "Centro", + "navbar.custom.position_left": "Izquierda", + "navbar.custom.position_right": "Derecha", + "navbar.custom.preview_as": "Previsualizar como", + "navbar.custom.regular": "Normal", + "navbar.custom.remove": "Eliminar", + "navbar.custom.remove_bar": "Quitar de la barra", + "navbar.custom.render": "Render", + "navbar.custom.reset": "Restablecer barra de navegación", + "navbar.custom.reset_default": "Restablecer a predeterminado", + "navbar.custom.right": "Mover a la derecha", + "navbar.custom.select": "Ningún hueco seleccionado. Selecciona un hueco en la barra para moverlo, quitarlo o cambiar cómo se muestra.", + "navbar.custom.separators_full": "La barra ya tiene cuatro separadores.", + "navbar.custom.show": "Mostrar barra de navegación", + "navbar.custom.shows": "Muestra", + "navbar.custom.size": "Tamaño", + "navbar.custom.stale": "La barra cambió. Selecciona el hueco de nuevo.", + "navbar.custom.title": "Barra de navegación", + "navbar.custom.unknown": "Este hueco no está disponible.", + "navbar.description.ambient_occlusion": "Oscurece pliegues y esquinas para dar profundidad.", + "navbar.description.display": "Contiene los ajustes de vista: preestablecido gráfico, rejilla, sombra de suelo, oclusión ambiental", + "navbar.description.fit": "Encuadra la selección, o todo el modelo si no hay nada seleccionado.", + "navbar.description.grid": "Muestra u oculta la rejilla de suelo.", + "navbar.description.ground_shadow": "Muestra u oculta la sombra bajo el modelo.", + "navbar.description.home": "Vuelve a la vista isométrica del modelo completo.", + "navbar.description.look_at": "Gira la vista para que mire la cara, plano o boceto abierto seleccionado.", + "navbar.description.orbit": "Gira la vista alrededor del modelo.", + "navbar.description.pan": "Desliza la vista lateral, arriba o abajo.", + "navbar.description.projection": "Cambia entre vistas ortográfica y perspectiva.", + "navbar.description.separator": "Una línea fina que agrupa huecos vecinos.", + "navbar.description.spin": "Mantiene la vista girando hasta que haces clic o pulsas una tecla.", + "navbar.description.visual_style": "Cambia cómo se dibuja el modelo: sombreado, alambre o con aristas ocultas.", + "navbar.description.zoom": "Acerca o aleja la vista del modelo.", + "navbar.display": "Visualización", + "navbar.fit": "Ajustar", + "navbar.graphics": "Gráficos", + "navbar.grid": "Rejilla", + "navbar.ground_shadow": "Sombra de suelo", + "navbar.home": "Inicio", + "navbar.later": "Luego", + "navbar.look_at": "Mirar", + "navbar.more": "Más", + "navbar.orbit": "Orbitar", + "navbar.orthographic": "Ortográfica", + "navbar.pan": "Desplazar", + "navbar.perspective": "Perspectiva", + "navbar.preset": "Preestablecido", + "navbar.projection": "Proyección", + "navbar.projection_heading": "Proyección", + "navbar.separator": "Separador", + "navbar.show": "Mostrar", + "navbar.spin": "Giro continuo", + "navbar.style.shaded": "Sombreado", + "navbar.style.shaded_with_edges": "Sombreado con aristas", + "navbar.style.shaded_with_hidden_edges": "Sombreado con aristas ocultas", + "navbar.style.wireframe": "Alambre", + "navbar.translucent": "Translúcido", + "navbar.value_tip": "{name} · {value}", + "navbar.visual_style": "Estilo visual", + "navbar.zoom": "Zoom", "needs two joints": "necesita dos uniones", "network.allow_once": "Permitir una vez", "network.always_allow": "Permitir siempre este servidor", @@ -1564,22 +1778,27 @@ "parts.automatic_name": "automático", "parts.dimension.across_flats": "Entrecaras", "parts.dimension.bore": "Diámetro interior", + "parts.dimension.d": "Diámetro nominal", "parts.dimension.head_diameter": "Diámetro cabeza", "parts.dimension.head_height": "Altura cabeza", "parts.dimension.height": "Altura", "parts.dimension.inner": "Diámetro interior", "parts.dimension.outer": "Diámetro exterior", + "parts.dimension.pitch": "Paso", "parts.dimension.socket": "Hexágono interior", "parts.dimension.socket_depth": "Profundidad hexágono", "parts.dimension.thickness": "Espesor", "parts.dimension.width": "Anchura", "parts.family.bearing": "Rodamiento rígido de bolas (envolvente)", + "parts.family.button_head_cap_screw": "Tornillo cabeza botón", "parts.family.dowel_pin": "Pasador cilíndrico", "parts.family.hex_bolt": "Tornillo de cabeza hexagonal", "parts.family.hex_nut": "Tuerca hexagonal", "parts.family.set_screw": "Prisionero con hexágono interior, punta plana", "parts.family.socket_head_cap_screw": "Tornillo cilíndrico con hexágono interior", "parts.family.washer": "Arandela plana", + "parts.holes": "Agujeros (opcional)", + "parts.placement_hint": "elige uno o más agujeros para colocar una pieza en cada uno, o Aceptar la coloca en el origen", "pattern.alignment_2": "Alineación 2", "pattern.alignment_hint": "Los recuentos incluyen el original. Centrar equilibra las copias; Invertir elige el lado adicional cuando el total es par.", "pattern.direction_1": "Dirección 1", @@ -1620,10 +1839,20 @@ "prefs.anti_aliasing_note": "Suaviza los bordes dentados.", "prefs.applies_on_restart": "Se aplica al reiniciar", "prefs.assistant": "Asistente", + "prefs.auto_operation": "Elegir la operación automáticamente", + "prefs.auto_operation_note": "Las ventanas de operación eligen Unir, Nuevo cuerpo o Cortar según lo que toque el nuevo material, u", "prefs.axes": "Ejes", "prefs.axis": "Eje", + "prefs.bodies_folders": "Carpetas de cuerpos", + "prefs.bodies_folders_always": "Siempre", + "prefs.bodies_folders_never": "Nunca", + "prefs.bodies_folders_several": "Solo varios cuerpos", "prefs.bottom": "Abajo", + "prefs.bottom_centre": "Centro abajo", + "prefs.bottom_right": "Derecha abajo", "prefs.browser": "Explorador", + "prefs.browser_eye_left": "Ojo junto a la flecha de plegado", + "prefs.browser_eye_left_note": "Las filas del explorador muestran su ojo de mostrar/ocultar junto a la flecha de plegado, como antes, en lugar de", "prefs.browser_reveal_on_hover": "Mostrar al pasar el puntero", "prefs.browser_show_note": "Visible mantiene el navegador en pantalla. Mostrar al pasar el puntero lo oculta hasta que el puntero llega a su lugar o a la pestaña fina de su borde izquierdo, y sigue abierto mientras el puntero está dentro o hay un menú suyo abierto.", "prefs.browser_shown": "Visible", @@ -1674,9 +1903,13 @@ "prefs.graphics_card_restart": "Surte efecto al reiniciar SolidCraft. WGPU_ADAPTER_NAME tiene prioridad sobre esta opción.", "prefs.graphics_note": "Cambia velocidad por suavidad. Cada opción muestra su efecto en una muestra.", "prefs.graphics_presets_note": "Los preajustes configuran juntas las opciones de abajo.", + "prefs.hide_constraint_glyphs_zoomed_out": "Ocultar glifos de restricción al hacer zoom out", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Los glifos de restricción salen de la vista cuando el boceto tiene suficiente zoom out como para que", "prefs.history": "Historial", "prefs.history_origin_bodies": "Mostrar origen y cuerpos al principio", "prefs.history_origin_bodies_note": "El Historial a la izquierda o bajo el explorador empieza con la carpeta Cuerpos del componente activo y el Origen, con sus ojos y menús como en el explorador. El Historial en la parte inferior no cambia.", + "prefs.history_play_button": "Botón Reproducir historial en los controles del Historial", + "prefs.history_play_button_note": "Pone Reproducir historial entre Retroceder un paso y Avanzar un paso en el Historial", "prefs.history_sketches": "Bocetos", "prefs.history_sketches_in_order": "En orden", "prefs.history_sketches_note": "Con Bocetos en Debajo de su operación, el Historial de la izquierda lista cada boceto un nivel dentro, bajo la primera operación construida a partir de él, siempre que haya ancho para nombres. La línea de tiempo en la parte inferior mantiene siempre el orden de creación.", @@ -1684,6 +1917,7 @@ "prefs.hold_stop_snapping": "Mantén pulsado para desactivar el ajuste", "prefs.icons_only": "Solo iconos", "prefs.imported_file": "Archivo importado", + "prefs.in_browser": "En explorador", "prefs.input": "Entrada", "prefs.interface": "Interfaz", "prefs.interface_language": "Idioma de la interfaz", @@ -1694,8 +1928,14 @@ "prefs.layout_note": "Compacta acopla los paneles a los bordes de la ventana. La translucidez del explorador solo está disponible en Flotante. Cambia el lado del Asistente en la página Asistente.", "prefs.left": "Izquierda", "prefs.left_click": "Clic izquierdo", + "prefs.line_drag_draws_one": "Pulsar y arrastrar dibuja una línea", + "prefs.line_drag_draws_one_note": "En la herramienta Línea, pulsar, arrastrar y soltar dibuja una línea desde el punto", + "prefs.linked_designs": "Diseños vinculados", "prefs.live_input": "Entrada en vivo", "prefs.live_preview": "Vista previa en vivo", + "prefs.logo": "Logotipo", + "prefs.logo_accent": "Acento", + "prefs.logo_theme": "Colores del tema", "prefs.max_file_size": "Tamaño máximo de archivo al abrir", "prefs.max_file_size_design": "Archivos de diseño", "prefs.max_file_size_flag": "Esta ejecución se inició con --max-file-size {size}, que se aplica hasta cerrar SolidCraft. Estos ajustes se aplican al iniciar sin esa opción.", @@ -1703,6 +1943,9 @@ "prefs.max_file_size_step": "Archivos STEP y de importación", "prefs.max_file_size_web": "La aplicación web abre archivos STEP y de importación de hasta 512 MB y diseños de hasta 256 MB: una pestaña del navegador no admite archivos mayores. La aplicación de escritorio permite aumentar estos límites.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Iconos grandes", + "prefs.menu_list": "Lista", + "prefs.menus": "Menús", "prefs.minutes": "minutos", "prefs.minutes_suffix": " min", "prefs.mouse": "Ratón y panel táctil", @@ -1722,38 +1965,94 @@ "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.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", "prefs.placement": "Colocación", "prefs.prefer_free_sketch_points": "Preferir los puntos de boceto móviles cuando los puntos se solapan", "prefs.preview_choices": "Vista previa de tus opciones", + "prefs.print_face_highlight": "Resalte de cara de impresión", + "prefs.print_face_highlight_tip": "Con qué intensidad se tiñe y contornea la cara de impresión de un cuerpo en la vista. 0 % s", + "prefs.profile_active": "Perfil activo", + "prefs.profile_applied": "Perfil aplicado", + "prefs.profile_changed": "Ajustes cambiados desde que se guardó el perfil. Guardar los mantiene en él.", + "prefs.profile_deleted": "Perfil eliminado.", + "prefs.profile_export": "Exportar perfil", + "prefs.profile_exported": "Perfil exportado.", + "prefs.profile_imported": "Perfil importado. Elígelo en Perfil activo para cambiar a él.", + "prefs.profile_name_hint": "Nombre del perfil, p. ej. Portátil", + "prefs.profile_rename": "Renombrar", + "prefs.profile_renamed": "Perfil renombrado.", + "prefs.profile_save": "Guardar", + "prefs.profile_saved": "Los ajustes actuales se han guardado en el perfil.", + "prefs.profile_skipped": "Perfil aplicado; {n} ajuste(s) conservaron su valor actual (ver registro).", + "prefs.profiles": "Perfiles de ajustes", + "prefs.profiles_note": "Atajos, barra de herramientas, navegación, menú radial y ajustes de interfaz, agrupados bajo un nombre y conmutables de una vez.", + "prefs.project_options_dialog": "Diálogo de opciones del proyecto", + "prefs.project_options_dialog_note": "Activado: elegir Geometría o Cuerpos y Vinculación de proyección antes de proyectar. Desactivado: mantener el flujo directo anterior.", "prefs.quality_default": "predeterminada", "prefs.quality_fastest": "la más rápida", "prefs.quality_slowest": "la más lenta", "prefs.quality_smoother": "más suave", + "prefs.quick_measure": "Medición rápida en la esquina de la vista", + "prefs.quick_measure_note": "El tamaño de la selección en la esquina inferior derecha de la vista: longitud de una arista, o Ø un", + "prefs.render_previous_geometry_budget": "Usar presupuesto de geometría previo de Render", + "prefs.render_previous_geometry_budget_help": "Restaurar el límite fijo previo (512 MiB en escritorio de 64 bits, 128 MiB en otros) en lugar del presupuesto basado en dispositivo.", "prefs.reports_saved": "{n} guardados", + "prefs.ribbon_reveal": "Mostrar resultados en la cinta", + "prefs.ribbon_reveal_note": "Al pasar el ratón por un resultado en Buscar comandos se muestra dónde está en la cinta: su pestaña, su botón o su fila en el desplegable de un panel. Un comando iniciado desde la búsqueda parpadea allí, y uno en ejecución desde un desplegable marca su panel.", "prefs.right_click": "Clic derecho", "prefs.saving": "Guardado automático y recuperación", "prefs.scale_note": "También Ctrl + / Ctrl − en la ventana. Los botones miden 28 px al 100%.", "prefs.scale_text": "Escala y texto", + "prefs.search": "Buscar preferencias", + "prefs.search_empty": "Sin coincidencias", "prefs.select": "Seleccionar", "prefs.select_one_item": "Selecciona un elemento", + "prefs.select_other_hidden": "Seleccionar geometría oculta con Seleccionar otro", + "prefs.select_other_hidden_help": "Activado: incluir cuerpos, bocetos y aristas ocultas u ocluidas cerca del puntero. Desactivado: us", "prefs.selecting": "Selección", "prefs.shift": "Mayús", "prefs.shift_add_ctrl_toggle": "Mayús solo añade; Ctrl/Cmd alterna", "prefs.shift_add_note": "Activado: Mayús añade y Ctrl/Cmd alterna. Desactivado (predeterminado): ambos alternan.", "prefs.shortcuts": "Atajos de teclado", + "prefs.single_surface_analysis": "Usar un solo análisis de superficie", + "prefs.single_surface_analysis_help": "Desactivado: el sombreado de superficie y la Continuidad pueden estar activos juntos. Activado: se mantiene el último análisis iniciado o modificado.", + "prefs.sketch_align_points": "Alinear con otros puntos del boceto al dibujar", + "prefs.sketch_align_points_note": "Al dibujar, un punto se alinea horizontalmente con un punto del boceto y verticalmente con otro a la vez, con una guía punteada, y el clic mantiene ambas alineaciones. Desactivado: solo con el punto anterior, un eje a la vez.", + "prefs.sketch_conflict_colour": "Geometría conflictiva en el color de conflicto", + "prefs.sketch_conflict_colour_note": "Mientras las restricciones de un boceto entran en conflicto, las curvas y puntos que una restricción fallida sujeta se dibujan en el color de conflicto, no solo su glifo o cota. Desactivado: conservan sus colores habituales.", + "prefs.sketch_construction_lines": "Herramientas extendidas de líneas de construcción", "prefs.sketch_exit_view": "Al terminar un boceto", "prefs.sketch_exit_view.keep": "Mantener la vista actual", "prefs.sketch_exit_view.previous": "Volver a la vista anterior", "prefs.sketch_exit_view.unless_rotated": "Volver salvo que se haya rotado", "prefs.sketch_exit_view_note": "A dónde va la vista tras Terminar boceto, Cancelar o doble Esc.", + "prefs.sketch_open_look_at": "Girar para mirar el boceto al abrirlo", + "prefs.sketch_open_look_at_note": "Desactivado: crear o editar un boceto deja la vista como está. Mirar orienta la vista hacia la cara del boceto.", + "prefs.sketch_recent_snap": "Auto-ajuste a centros y puntos medios recientes", + "prefs.sketch_recent_snap_note": "Al dibujar, ajusta el cursor donde las guías de los dos centros de círculo más recientes", "prefs.sketch_selection": "Boceto y selección", "prefs.sketch_selection_heading": "Boceto y selección", "prefs.sketch_selection_note": "Cómo se ajustan los bocetos y cómo seleccionan los clics.", + "prefs.sketch_state_icons": "Iconos de boceto en el color de su estado", + "prefs.sketch_state_icons_note": "En Historial y el explorador, el icono de un boceto toma el color de su estado (bajo restricción, totalmente restringido o en conflicto) y su info muestra el estado. Desactivado: todos los bocetos tienen el mismo icono.", + "prefs.sketch_tab_always": "Mantener la pestaña Boceto visible", + "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.sketching": "Bocetos", "prefs.snap_drawing_note": "Mientras dibujas o arrastras un punto del boceto.", "prefs.source": "Origen", "prefs.speed": "Velocidad", "prefs.stand_up_note": "Cuando un archivo STEP o IGES se abre con Z hacia arriba, ofrece un cuarto de giro sobre X.", + "prefs.stand_up_unknown": "Para archivos de programas desconocidos", + "prefs.stand_up_unknown.ask": "Preguntar en un diálogo", + "prefs.stand_up_unknown.note": "Mostrar aviso", + "prefs.stand_up_unknown.off": "No ofrecer", + "prefs.stand_up_unknown_note": "Cómo se hace la oferta cuando la cabecera del archivo no nombra ningún programa conocido que guarde con Y arriba. El aviso solo se muestra cuando el archivo parece boca abajo; Preguntar en un diálogo pregunta sobre cada uno, como antes.", + "prefs.status_bar": "Barra de estado", + "prefs.status_bar_note": "Una barra fina en el borde inferior de la ventana muestra qué se está editando, el estado del boceto abierto en su color y las unidades del diseño; clic en las unidades las cambia. El estado del boceto ya no se muestra en la esquina de la vista.", "prefs.status_done": "Completado", "prefs.status_error": "Error", "prefs.status_selected": "Seleccionado", @@ -1761,18 +2060,30 @@ "prefs.supersampling_brief": "Dibuja la vista a mayor tamaño y la reduce con un filtro: muy nítida, pero más pesada.", "prefs.tab_accent_note": "El color de acento sigue a la pestaña activa", "prefs.theme_file": "archivo de tema", + "prefs.theme_file_or_paste": "archivo o pegar", "prefs.theme_follow": "Seguir un archivo de tema", "prefs.theme_follow_error": "No aplicado: {error}. Se mantiene el último tema válido.", "prefs.theme_follow_launch": "Solo en esta sesión (--theme-file); se recarga cada vez que otra herramienta reescribe el archivo.", "prefs.theme_follow_note": "Se recarga cada vez que otra herramienta reescribe el archivo. Elegir un tema deja de seguirlo.", "prefs.theme_follow_off": "Desactivado", "prefs.theme_follow_stop": "Dejar de seguir", + "prefs.theme_import_hint": "Haz clic para elegir un archivo de tema, arrastra uno aquí, o pulsa Ctrl+V (Cmd+V en Mac) con un tema copiado", + "prefs.theme_paste": "Pegar tema", + "prefs.theme_paste_none": "El portapapeles no contiene ningún tema de SolidCraft", "prefs.timeline_half_hour": "−30 min", "prefs.timeline_now": "ahora", "prefs.timeline_quarter_hour": "−15", + "prefs.toast_place": "Avisos", "prefs.toggle_item": "Añade el elemento o vuelve a quitarlo", "prefs.toolbar": "Barra de herramientas", + "prefs.toolbar_tabs": "Pestañas de la barra de herramientas", "prefs.top": "Arriba", + "prefs.trackpad_zoom_modifier": "Zoom con scroll de dos dedos manteniendo", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Mantén solo esta tecla para hacer zoom en el puntero con scroll vertical. Desactivado mantiene el comportamiento de scroll actual.", + "prefs.trackpad_zoom_modifier.off": "Desactivado", "prefs.translucent": "Translúcido", "prefs.under_browser": "Debajo del explorador", "prefs.under_cursor": "Bajo el cursor", @@ -1782,16 +2093,24 @@ "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.", "prefs.viewport": "Visor", + "prefs.viewport_analysis_status": "Mostrar estado de análisis del visor", + "prefs.viewport_analysis_status_help": "Mostrar análisis activos sobre el visor, abrir sus leyendas desde cada elemento, y", "prefs.viewport_background": "Fondo", "prefs.viewport_background_colour": "Color", "prefs.viewport_background_theme": "Degradado del tema", "prefs.wheel": "Rueda", "prefs.wheel_trackpad": "Rueda y panel táctil", "prefs.workspace": "Espacio de trabajo", + "prefs.zoom_stop_at_model": "Detener zoom en el modelo", + "prefs.zoom_stop_at_model_tip": "El ratón 3D y el zoom de la app del dispositivo van hacia la superficie central de la vista; el zoom por arrastre mantiene el punto donde empezó el arrastre. Desactivado: el zoom puede atravesar el modelo. Rueda y pellizco usan Zoom al cursor por separado", "prefs.zoom_toward_cursor": "Zoom hacia el cursor", "properties.title": "Propiedades: {name}", "recovery.days_ago": "hace {count} días", "recovery.detail": "{file} · {features} operaciones · autoguardado {when}", + "recovery.discard_all": "Descartar todo", + "recovery.discard_all_detail": "Esto no se puede deshacer.", + "recovery.discard_all_title": "¿Descartar {count} autoguardados?", + "recovery.discard_all_title_one": "¿Descartar 1 autoguardado?", "recovery.hours_ago": "hace {count} h", "recovery.just_now": "justo ahora", "recovery.min_ago": "hace {count} min", @@ -1835,7 +2154,48 @@ "render.aging_upward": "Depósito en caras superiores", "render.all_controls_help": "Inspección de todos los controles. Las secciones permiten editar por contexto sin cambiar la petición subyacente.", "render.all_shots_label": "Todas las tomas", + "render.appearance_editor.absorption": "Absorción", + "render.appearance_editor.base_colour": "Color base", + "render.appearance_editor.body_reason": "Las vinculaciones ópticas afectan a un cuerpo entero. Elige Cuerpo para editar estos controles.", + "render.appearance_editor.boundary": "Límite", + "render.appearance_editor.box": "Caja", + "render.appearance_editor.choose_image": "Elegir imagen…", + "render.appearance_editor.closed": "Sólido cerrado", + "render.appearance_editor.coat": "Capa transparente", + "render.appearance_editor.cpu_reason": "Requiere ray tracing en CPU.", + "render.appearance_editor.glass": "Vidrio y absorción", + "render.appearance_editor.glass_enable": "Usar vidrio físico en este cuerpo", + "render.appearance_editor.glass_note": "El vidrio cerrado usa distancia geométrica. No puede compartir cuerpo con capa transparente o mapas.", + "render.appearance_editor.gpu_reason": "Requiere path tracing en GPU.", + "render.appearance_editor.ior": "IOR", + "render.appearance_editor.maps": "Mapas", + "render.appearance_editor.normal": "Normal", + "render.appearance_editor.normal_strength": "Intensidad de normal", + "render.appearance_editor.origin": "Origen", + "render.appearance_editor.recipe": "Receta", + "render.appearance_editor.reflection_sampling": "Muestreo para todos los cuerpos reflectivos", + "render.appearance_editor.reflections": "Reflexiones", + "render.appearance_editor.remove": "Eliminar guardado", + "render.appearance_editor.remove_note": "Elimina la óptica guardada de esta toma. La geometría y los aspectos asignados se conservan.", + "render.appearance_editor.remove_title": "¿Eliminar óptica guardada?", + "render.appearance_editor.replace": "Reemplazar la óptica guardada de este cuerpo", + "render.appearance_editor.revalidate": "Revalidar este cuerpo", + "render.appearance_editor.save": "Guardar esta óptica", + "render.appearance_editor.save_reason": "Guardar esta óptica requiere vidrio físico en un cuerpo de GPU actual. La capa y los mapas de imagen locales quedan temporales.", + "render.appearance_editor.saved": "Guardado con esta toma", + "render.appearance_editor.saved_state": "Esta óptica está guardada con esta toma.", + "render.appearance_editor.scatter_reason": "La dispersión interior necesita un límite de sólido cerrado.", + "render.appearance_editor.temporary": "Esta óptica es temporal en esta pestaña. Guárdala para conservarla con la toma.", + "render.appearance_editor.thickness": "Espesor", + "render.appearance_editor.thickness_reason": "El espesor se aplica a láminas finas; el vidrio cerrado usa distancia geométrica.", + "render.appearance_editor.thin": "Lámina fina", + "render.appearance_editor.u_repeat": "Repetición U", + "render.appearance_editor.v_repeat": "Repetición V", + "render.appearance_editor.v_up": "Textura V hacia arriba", + "render.appearance_editor.weight": "Peso", "render.appearance_physics_separate": "El aspecto visual no asigna densidad, propiedades térmicas ni otros datos de ingeniería.", + "render.appearances": "Aspectos", + "render.assign_help": "Haz clic para aplicar a la selección; arrastra para previsualizar y soltar, o haz clic sin selección para pintar.", "render.availability_native": "Composición nativa; revisar antes de aplicar", "render.availability_projection": "Proyección compatible; consulta alcance y omisiones", "render.availability_reference": "Datos de referencia; no es una asignación automática", @@ -1874,6 +2234,7 @@ "render.bloom_threshold": "Umbral lineal de altas luces", "render.browse_capabilities": "Consultar catálogo completo de capacidades", "render.browse_environments": "Explorar entornos HDR originales", + "render.browse_looks": "Explorar…", "render.browse_native_scenes": "Explorar estudios nativos", "render.browse_physical": "Consultar referencias físicas", "render.browse_quality": "Consultar especificaciones de calidad", @@ -1890,6 +2251,89 @@ "render.camera_set": "Establecer cámara de render", "render.camera_target": "Posición del objetivo · mm", "render.camera_yaw": "Azimut", + "render.category_metal": "Metal", + "render.category_paint": "Pintura", + "render.category_plastic": "Plástico", + "render.category_wood": "Madera", + "render.check_opaque": "Transparencia: solo superficies opacas; se admite alfa de fondo.", + "render.check_sorted_alpha": "Transparencia: aproximación alfa ordenada.", + "render.check_transmission": "Transparencia: transmisión física.", + "render.children.absolute_floor": "Suelo absoluto", + "render.children.adaptive": "Adaptativo", + "render.children.add_look_reason": "Añade un aspecto para editar su estado activado e intensidad.", + "render.children.all_shots": "Todas las tomas", + "render.children.bloom": "Bloom", + "render.children.check": "Comprobar", + "render.children.check_now": "Comprobar ahora", + "render.children.chromatic": "Cromático", + "render.children.compact": "Compacto", + "render.children.contrast": "Contraste", + "render.children.copy_image": "Copiar", + "render.children.copy_job": "Copiar JSON de trabajo", + "render.children.copy_request": "Copiar JSON de petición", + "render.children.coverage_error": "Error de cobertura", + "render.children.cpu": "Ray tracing en CPU", + "render.children.denoise": "Reducir ruido en la imagen final", + "render.children.depth": "Profundidad de trayecto", + "render.children.distortion": "Distorsión", + "render.children.edge_samples": "Muestras de borde", + "render.children.effects_banner": "Estos efectos necesitan path tracing en GPU. Abrir esto nunca cambia el método.", + "render.children.enable_adaptive": "Activar pilotos independientes para editar los límites adaptativos.", + "render.children.enable_bloom": "Activar bloom para editar su intensidad, umbral y radio.", + "render.children.finishing": "Acabado", + "render.children.flare": "Fogonazo", + "render.children.fresnel": "Estratos de Fresnel", + "render.children.ghosts": "Anillos fantasma", + "render.children.glass": "Transporte de vidrio", + "render.children.go_to": "Ir a {editor}", + "render.children.grain": "Grano", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Conservar máster lineal bruto", + "render.children.last_result": "Último: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efectos de lente", + "render.children.look_summary": "{tone} · {count} aspectos", + "render.children.looks": "Aspectos creativos", + "render.children.memory": "Memoria", + "render.children.method": "Método", + "render.children.minimum": "Mínimo", + "render.children.needs_cpu": "Requiere ray tracing en CPU. Abrir esto nunca cambia el método.", + "render.children.nested": "Dieléctricos anidados certificados", + "render.children.not_checked": "Sin comprobar", + "render.children.path": "Path tracing", + "render.children.pilots": "Muestras piloto", + "render.children.preview": "Carga de previsualización", + "render.children.progressive_reason": "Elige Progresivo para editar su cuenta de muestras y semilla.", + "render.children.radius": "Radio", + "render.children.reflect_depth": "Profundidad de reflexión", + "render.children.reflect_samples": "Muestras de reflexión", + "render.children.regrade": "Regradado", + "render.children.relative_error": "Error relativo", + "render.children.render": "Renderizar", + "render.children.render_shot": "Renderizar toma", + "render.children.renderer": "Renderizador", + "render.children.renderers": "Renderizadores", + "render.children.request": "Esta petición", + "render.children.reset_finishing": "Restablecer acabado", + "render.children.review_quality": "Revisar una especificación de calidad…", + "render.children.rgb_gain": "Ganancia RGB", + "render.children.samples": "Muestras", + "render.children.sampling": "Muestreo", + "render.children.saturation": "Saturación", + "render.children.scene_summary": "{bodies} cuerpos · {triangles} triángulos", + "render.children.seed": "Semilla", + "render.children.sharpening": "Enfoque", + "render.children.single_image": "Imagen única", + "render.children.soft_focus": "Enfoque suave", + "render.children.streak": "Estela", + "render.children.strength": "Intensidad", + "render.children.studio": "Estudio", + "render.children.threshold": "Umbral", + "render.children.tone": "Tono", + "render.children.under_hood": "Bajo el capó", + "render.children.vignette": "Vigneteo", + "render.children.workstation": "Estación de trabajo", + "render.choose_look": "Elige un aspecto en Aspectos primero", "render.chromatic_offset": "Separación cromática", "render.clear_stage": "Eliminar el escenario guardado", "render.copy_request": "Copiar JSON", @@ -1988,6 +2432,145 @@ "render.effects_help": "Combina filtros de color ordenados y efectos de lente en imagen. Render y reedición del máster usan los mismos ajustes guardados. El EXR bruto no cambia. Son controles artísticos originales, no emulaciones calibradas de lentes o películas comerciales.", "render.effects_reset_lens": "Restablecer efectos de lente", "render.effects_seed": "Semilla del patrón", + "render.emitted_axis": "Eje emitido", + "render.env_advanced": "Avanzado", + "render.env_backdrop": "Fondo", + "render.env_backdrop_pending": "La visibilidad del entorno y el desenfoque de fondo no los admite el renderizador todavía.", + "render.env_blur": "Desenfoque", + "render.env_change": "Cambiar", + "render.env_choose_source": "Elige un entorno en Estudios y Entornos antes de editar la iluminación.", + "render.env_clear": "Limpiar", + "render.env_colour": "Color", + "render.env_environments": "Entornos", + "render.env_ground": "Suelo", + "render.env_ground_pending": "El suelo físico recibe sombras como superficie difusa finita. La captura solo de sombras y los reflejos de suelo no se admiten todavía.", + "render.env_intensity": "Intensidad", + "render.env_invalid_edit": "Edición de entorno no válida.", + "render.env_physical_ground": "Suelo físico", + "render.env_preview_help": "Muestra ilustrativa de esfera; no es una previsualización del entorno seleccionado.", + "render.env_reflection": "Reflexión", + "render.env_ring_help": "Arrastra el anillo para rotar la iluminación. Mantén Mayús para saltos de 15°.", + "render.env_rotate": "Rotar", + "render.env_set": "Configurar entorno de render", + "render.env_shadow_catcher": "Capturador de sombras", + "render.env_show_environment": "Mostrar entorno", + "render.env_source": "Fuente", + "render.env_stage_details": "Las ediciones de suelo requieren ray tracing y un solo panel de suelo. Usa Escena para editar otros paneles de escenario.", + "render.env_studios": "Estudios", + "render.environment.albedo": "Albedo", + "render.environment.atmosphere": "Atmósfera", + "render.environment.backdrop_note": "Un fondo solo lo ve la cámara. Nunca ilumina, proyecta sombras ni refleja, y se rechaza con salida transparente.", + "render.environment.blur_pending": "El desenfoque no está disponible: el renderizador no tiene ajuste de desenfoque de fondo.", + "render.environment.builtin": "HDR integrado", + "render.environment.check": "Comprobar archivo", + "render.environment.checked": "Archivo HDR comprobado. Guardar incrusta estos bytes.", + "render.environment.choose": "Elegir HDR…", + "render.environment.constant": "Constante", + "render.environment.cyclorama": "Ciclorama", + "render.environment.embedded": "Guardado dentro de este diseño", + "render.environment.end": "Fin", + "render.environment.extinction": "Extinción", + "render.environment.file": "Archivo", + "render.environment.fit": "Ajustar y guardar suelo", + "render.environment.floor_wall": "Suelo y pared", + "render.environment.gap": "Separación", + "render.environment.gradient": "Gradiente", + "render.environment.gradient_gpu": "El fondo gradiente necesita path tracing en GPU.", + "render.environment.ground": "Suelo y escenario", + "render.environment.hdr": "HDR", + "render.environment.hdr_file": "Archivo HDR", + "render.environment.horizon": "Horizonte", + "render.environment.horizontal": "Horizontal", + "render.environment.keep": "Conservar", + "render.environment.lighting_samples": "Muestras de iluminación", + "render.environment.margin": "Margen de extensión", + "render.environment.max": "Límite máximo", + "render.environment.medium": "Medio participante", + "render.environment.min": "Límite mínimo", + "render.environment.mode": "Modo", + "render.environment.needs_gpu": "Requiere path tracing en GPU.", + "render.environment.none": "Ninguno", + "render.environment.orientation": "Orientación e intensidad", + "render.environment.phase": "Fase", + "render.environment.primaries": "Tratar primarias no especificadas como sRGB lineal", + "render.environment.primaries_gpu": "Interpretar primarias no especificadas necesita path tracing en GPU.", + "render.environment.radial": "Radial", + "render.environment.radial_only": "Centro y radio se aplican al modo Radial.", + "render.environment.reflectance": "Reflectancia", + "render.environment.remove": "Eliminar escenario", + "render.environment.remove_confirm": "¿Eliminar el escenario guardado?", + "render.environment.remove_note": "Elimina los {count} paneles pasivos de esta toma. El fondo de cámara se mantiene.", + "render.environment.replace": "Confirmar reemplazo del escenario existente", + "render.environment.rotation_hdr": "La rotación necesita una fuente HDR para ray tracing en CPU.", + "render.environment.samples_cpu": "Las muestras de iluminación las fija el presupuesto de path tracing en GPU; este ajuste necesita ray tracing en CPU.", + "render.environment.saved_panels": "Paneles guardados {count}", + "render.environment.sky_ground": "Suelo", + "render.environment.sky_note": "Gradiente de cielo original para iluminación; no es una atmósfera calibrada.", + "render.environment.source_raytrace": "Las fuentes de entorno necesitan ray tracing en CPU o GPU.", + "render.environment.stage_raytrace": "Los escenarios físicos necesitan ray tracing en CPU o GPU.", + "render.environment.start": "Inicio", + "render.environment.vertical": "Vertical", + "render.environment.world": "Mundo", + "render.environment.world_pending": "El fondo mundial no está disponible: la visibilidad de cámara del entorno no tiene ajuste guardado aún.", + "render.environment.zenith": "Cenit", + "render.environment_tiles_require_raytrace": "Los entornos requieren ray tracing en CPU o GPU. Selecciónalo en el editor de Salida.", + "render.error_area_samples": "area_samples debe ser 1, 4, 16 o 64", + "render.error_binding_allocation": "no se pudo reservar la vinculación de material", + "render.error_body_bound": "la previsualización del producto supera su límite de cuerpo capturado", + "render.error_checkpoint_dimensions": "las dimensiones del punto de control difieren de la petición capturada", + "render.error_decode_budget": "desbordamiento del presupuesto de decodificación de imagen", + "render.error_denoise_setting": "ajuste de denoising nativo no válido", + "render.error_display_dimensions": "dimensiones de visualización de imagen no válidas", + "render.error_display_fit": "la visualización de imagen no puede ajustarse a este dispositivo", + "render.error_display_height": "desbordamiento de altura de visualización", + "render.error_display_width": "desbordamiento de ancho de visualización", + "render.error_duplicate_face": "aspecto de cara de producto duplicado", + "render.error_export_document": "el documento de exportación de render cambió al elegir su destino", + "render.error_face_cap": "la previsualización del producto supera el límite de admisión de aspectos de cara", + "render.error_finish_gesture": "finaliza el gesto de modelado antes de previsualizar", + "render.error_finish_operation": "finaliza la operación de modelado actual antes de entrar en Render", + "render.error_fixed_preview": "la previsualización de estudio fija requiere exposición 0, rotación de entorno 0 e intensidad 1", + "render.error_geometry_size": "desbordamiento de tamaño de geometría de producto", + "render.error_glass_recipe": "receta de vidrio desconocida", + "render.error_gpu_preview": "la previsualización de producto en GPU no está disponible en este host. Los ajustes guardados siguen disponibles.", + "render.error_gpu_samples": "cuenta de muestras en GPU no admitida", + "render.error_image_dimensions": "las dimensiones de imagen de render no coinciden con su trabajo", + "render.error_image_profile": "la imagen de render supera su perfil de recursos capturado", + "render.error_interactive_command": "comando interactivo de Render no vinculado", + "render.error_job_action": "no es una acción interactiva de trabajo de imagen", + "render.error_job_identity": "identidad de trabajo de render no válida: {error}", + "render.error_library_reply": "respuesta de biblioteca de render no válida", + "render.error_material_binding": "vinculación de material faltante", + "render.error_no_completed_image": "el render no tiene imagen completada", + "render.error_no_geometry": "sin geometría de producto", + "render.error_no_scene": "sin escena de producto", + "render.error_opacity": "opacidad de aspecto de producto no válida", + "render.error_pbr_opacity": "PBR opaco requiere opacidad 1", + "render.error_physical_glass": "la receta seleccionada no es vidrio físico", + "render.error_preview_device": "la previsualización de producto requiere un dispositivo instalado con búfer de profundidad", + "render.error_product_triangle": "triángulo de producto no válido", + "render.error_provisional_identity": "identidad provisional de render o cuenta de muestras no válida", + "render.error_quality_bounds": "la proyección de calidad supera los límites nativos", + "render.error_quality_method": "la proyección de calidad requiere el mismo método GPU explícito", + "render.error_quality_projection": "proyección de calidad nativa no válida", + "render.error_regrade_identity": "identidad de regradado no válida: {error}", + "render.error_scene_bodies": "cuerpos de escena de render faltantes", + "render.error_scene_body_limit": "la escena de render supera su límite de cuerpos", + "render.error_scene_identity": "identidad de escena de render no válida", + "render.error_setup_panicked": "el setup de path tracer en GPU entró en pánico", + "render.error_setup_stopped": "el trabajador de setup se detuvo sin devolver un host", + "render.error_setup_worker": "no se pudo iniciar el trabajador de setup: {error}", + "render.error_source_preview": "la previsualización de fuente supera su sobre de bytes", + "render.error_surface_normals": "PBR requiere normales de superficie no nulas", + "render.error_surface_reply": "respuesta de superficie autorizada no válida", + "render.error_triangle_bound": "la previsualización del producto supera su límite de origen de ocho millones de triángulos", + "render.error_triangle_count": "desbordamiento de cuenta de triángulos de producto", + "render.error_upload_slot": "ranura de subida de previsualización de producto no disponible", + "render.error_vertex_allocation": "falló la asignación de vértices de previsualización de producto", + "render.error_vertex_envelope": "la previsualización del producto supera su sobre de carga de vértices de {limit} MiB; la geometría no se simplificó", + "render.error_vertex_packing": "el empaquetado de previsualización de producto difiere de su carga de vértices admitida", + "render.error_visible_solid": "Crea un sólido visible en Diseño para previsualizarlo aquí.", + "render.exit_focus": "Salir de foco", "render.export_display": "Exportar: PNG de visualización", "render.export_image_label": "Exportar imagen", "render.export_linear": "Exportar: EXR lineal", @@ -2001,6 +2584,34 @@ "render.flare_streak": "Estela estilo anamórfico", "render.flare_strength": "Intensidad del flare", "render.flare_threshold": "Umbral lineal del flare", + "render.fold_browser": "Plegar explorador", + "render.frame_aspect": "Proporción", + "render.frame_centre_mark": "Marca central", + "render.frame_copy_limit": "Sin nombre disponible para toma duplicada", + "render.frame_copy_name": "{name} copia {number}", + "render.frame_custom": "Personalizado", + "render.frame_distance_unit": " × tamaño", + "render.frame_duplicate": "Duplicar", + "render.frame_exposure": "Exposición", + "render.frame_fov": "Campo de visión", + "render.frame_guides": "Guías", + "render.frame_invalid_aspect": "Preestablecido de proporción no válido", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Nombre", + "render.frame_output_size": "Tamaño de salida", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Retrato", + "render.frame_presets": "Preestablecidos de encuadre", + "render.frame_presets_pending": "Llega con el editor de Toma", + "render.frame_projection": "Proyección", + "render.frame_render_shot": "Renderizar esta toma", + "render.frame_rule_thirds": "Regla de los tercios", + "render.frame_safe_area": "Área segura", + "render.frame_shot_title": "Toma · {name}", + "render.frame_square": "1:1 · Cuadrado", + "render.frame_tall": "9:16 · Vertical", + "render.frame_thirds": "Tercios", + "render.frame_wide": "16:9 · Panorámico", "render.gpu_depth": "Profundidad máxima del trazado", "render.gpu_options_help": "Las muestras, la profundidad, la semilla y la exposición se guardan con esta configuración. Solo se guarda el método de imagen activo.", "render.gpu_path_failed": "Trazado GPU no disponible", @@ -2028,6 +2639,7 @@ "render.heading_results": "RESULTADOS", "render.heading_shots": "TOMAS", "render.hide_shots_label": "Ocultar tira de tomas", + "render.hover_look": "{object} · {look} · clic para seleccionar", "render.image_advanced": "Opciones avanzadas de GPU", "render.image_cancelled": "Renderizado cancelado", "render.image_cancelling": "Cancelando", @@ -2058,11 +2670,43 @@ "render.image_use_largest": "Usar el tamaño máximo para esta proporción:", "render.image_width": "Anchura", "render.include_rig_cards": "Incluir tarjetas y banderas pasivas", + "render.inner_half_angle": "Semángulo interior", "render.inspect_scene": "Actualizar resumen de la escena", "render.inspector": "Inspector", + "render.inspector_advanced": "Avanzado", + "render.inspector_all_users": "Todos los {count} usuarios", + "render.inspector_applies_to": "Se aplica a", + "render.inspector_both_users": "Ambos usuarios", + "render.inspector_chain": "De dónde viene este aspecto", + "render.inspector_colour": "Color", + "render.inspector_edit": "Editar", + "render.inspector_finish": "Finalizar", + "render.inspector_from_material": "Desde Material", + "render.inspector_gloss": "Brillo", + "render.inspector_material": "Material", + "render.inspector_matte": "Mate", + "render.inspector_metal": "Metal", + "render.inspector_opacity": "Opacidad", + "render.inspector_opaque_help": "El acabado y los campos ópticos usan PBR opaco (opacidad 1). Opacidad menor usa sombreado escalar.", + "render.inspector_pick_target": "Elige un objetivo de este tipo para inspeccionar su aspecto.", + "render.inspector_remove": "Eliminar sobreescritura", + "render.inspector_replace": "Reemplazar…", + "render.inspector_reset": "Restablecer", + "render.inspector_satin": "Satén", + "render.inspector_save_library": "Guardar en biblioteca…", + "render.inspector_set_all": "Aspectos mezclados. Editar establece el aspecto en todos los objetivos seleccionados.", + "render.inspector_soon": "Pronto", + "render.inspector_target_limit": "Selecciona como máximo 10000 objetivos para inspeccionar aspectos.", + "render.inspector_textures": "Texturas", + "render.inspector_this_body": "Este cuerpo", + "render.inspector_this_component": "Este componente", + "render.inspector_this_face": "Esta cara", + "render.inspector_this_target": "Este objetivo", + "render.inspector_used_by": "Usado por {count}", "render.keep_editing": "Seguir editando", "render.layer_down": "Bajar", "render.layer_enabled": "Capa activada", + "render.layer_off_suffix": " (apagado)", "render.layer_remove": "Eliminar capa", "render.layer_strength": "Intensidad de capa", "render.layer_up": "Subir", @@ -2070,8 +2714,12 @@ "render.lens_effects_title": "Lente y fotografía", "render.library_all": "Catálogo", "render.library_all_states": "Todos los estados", + "render.library_appearance_available": "Aspecto disponible", + "render.library_applicable": "Recursos aplicables", "render.library_applied": "Variante del recurso aplicada. Comprueba la solicitud completa antes de renderizar.", + "render.library_apply_appearance": "Aplicar aspecto", "render.library_apply_hdr": "Guardar con este entorno HDR", + "render.library_apply_hint": "Clic para aplicar · arrastrar sobre el modelo", "render.library_apply_maps": "Usar mapas de superficie GPU", "render.library_apply_studio": "Guardar la variante de iluminación", "render.library_apply_uniform": "Aplicar aspecto opaco uniforme", @@ -2087,15 +2735,23 @@ "render.library_extent": "Extensión del objeto", "render.library_favorite": "Añadir favorito", "render.library_favorites": "Favoritos", + "render.library_filters": "Catálogo y disponibilidad", + "render.library_grid": "Cuadrícula", "render.library_hdr": "Entornos HDR incluidos", "render.library_hdr_help": "Incorpora el HDR original a la configuración guardada. Convierte explícitamente el origen azimutal; tras guardar no se necesita ningún archivo externo.", + "render.library_in_design": "En Diseño", "render.library_inspect_only": "Selecciona para inspeccionar; no se aplica nada automáticamente. Favoritos e historial pertenecen a la sesión de este documento.", + "render.library_ior": "IOR", "render.library_light_concepts": "Conceptos de iluminación", + "render.library_list": "Lista", "render.library_maps_help": "Los mapas integrados se pueden guardar con fuentes fijadas y una correspondencia local de instancias. Los ajustes ópticos temporales siguen siendo un modo aparte.", "render.library_material_concepts": "Conceptos de materiales", + "render.library_metallic": "Metálico", "render.library_native_scenes": "Estudios nativos", "render.library_next": "Página siguiente", "render.library_omitted": "No incluido", + "render.library_opacity": "Opacidad", + "render.library_other": "Otros aspectos", "render.library_physical_profiles": "Perfiles de referencia física", "render.library_previous": "Página anterior", "render.library_quality_concepts": "Especificaciones de calidad", @@ -2105,6 +2761,7 @@ "render.library_results": "Recursos encontrados", "render.library_rig_help": "Se ajusta una sola vez a un centro y una extensión física explícitos. Los conjuntos completos incluyen tarjetas pasivas; los bloqueos de calibración siguen impidiendo el guardado. Antes de guardar se comprueba una separación conservadora entre emisores y tarjetas.", "render.library_rigs": "Configuraciones de iluminación creadas", + "render.library_roughness": "Rugosidad", "render.library_save_maps": "Guardar los mapas en esta configuración", "render.library_scene_bounds": "Usar los límites de la escena capturada", "render.library_scenes": "Especificaciones de escenas", @@ -2128,17 +2785,96 @@ "render.library_unfavorite": "Quitar favorito", "render.library_uniform_ack": "Usar la aproximación uniforme sin efectos espaciales ni avanzados", "render.library_usable": "Proyecciones ejecutables", + "render.library_users": "Usuarios", + "render.light_aim": "Apuntar a", "render.light_anchor_pending": "Cambiar el sistema de coordenadas de la luz no está soportado aún.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Elige ray tracing en CPU o un estudio GPU explícito para editar luces. Las luces de estudio GPU heredadas permanecen de solo lectura.", + "render.light_behaviour": "COMPORTAMIENTO", + "render.light_default_fill": "Relleno", + "render.light_default_key": "Principal", + "render.light_default_number": "Luz {number}", + "render.light_default_rim": "Contorno", + "render.light_delete": "Eliminar", + "render.light_distance": "Distancia", + "render.light_duplicate": "Duplicar", + "render.light_elevation": "Elevación", + "render.light_gpu_punctual": "Luces Puntual y Focal están disponibles con ray tracing en CPU.", + "render.light_hard_shadow": "Esta luz tiene sombras duras; la suavidad del emisor no está disponible.", + "render.light_intensity": "Intensidad", + "render.light_missing": "Esta luz ya no existe. Selecciona una luz en el Explorador.", + "render.light_place_highlight": "Colocar por destello", + "render.light_place_pending": "La colocación por destello necesita selección de luz en el visor y no se admite todavía.", + "render.light_position": "POSICIÓN", + "render.light_preview_help": "La vista en vivo no tiene sombras; las luces de Área usan una aproximación de muestra central. El render usa el modelo de luz completo.", + "render.light_product_centre": "Centro del producto", + "render.light_size": "Tamaño", + "render.light_softness": "Suavidad", + "render.light_temperature": "Temperatura de color", + "render.light_temperature_help": "Tinte de cuerpo negro aproximado; se guarda como RGB lineal.", + "render.light_type": "Tipo", + "render.light_type_area": "Área", + "render.light_type_point": "Puntual", + "render.light_type_spot": "Focal", + "render.light_type_sun": "Solar", + "render.light_view_only": "Solo y silenciar afectan a esta vista y sus renders; nunca se guardan en el diseño.", + "render.light_world_origin": "Origen mundial", + "render.lighting_aim_point": "Punto de mira", + "render.lighting_apply": "Aplicar preestablecido", + "render.lighting_area_samples": "Muestras de área", + "render.lighting_budget": "Presupuesto", + "render.lighting_budget_help": "Las cuentas son de solo lectura. Añade, duplica o elimina luces para cambiarlas; el método fija los límites.", + "render.lighting_colour": "Color", + "render.lighting_consent": "Reemplazar las luces direccionales con este preestablecido", + "render.lighting_custom": "Luces personalizadas", + "render.lighting_custom_help": "Elegir Personalizado convierte el preestablecido en luces editables, una fila cada una en el árbol.", + "render.lighting_direction": "Dirección X Y Z", + "render.lighting_direction_help": "O escribe una dirección hacia la luz (X Y Z).", + "render.lighting_distance_unit": " × tamaño", + "render.lighting_edge_u": "Vector lateral U", + "render.lighting_edge_v": "Vector lateral V", + "render.lighting_falloff": "Caída", + "render.lighting_falloff_help": "La intensidad se cita en la distancia de referencia.", + "render.lighting_height": "Altura V", + "render.lighting_minimum": "Mínimo", + "render.lighting_mute": "Silenciar esta luz", + "render.lighting_point": "Punto", + "render.lighting_preset": "Preestablecido de estudio", + "render.lighting_preset_cpu": "Los preestablecidos de estudio direccionales requieren ray tracing en CPU. La iluminación GPU usa luces Solar y de Área explícitas.", + "render.lighting_preset_row": "Preestablecido", + "render.lighting_radiance": "Radiancia", + "render.lighting_reference": "Referencia", + "render.lighting_review_help": "Reemplaza la lista de luces direccionales. Conserva luces puntuales y de área, entorno, sombras, cámara y todos los demás ajustes de la toma.", + "render.lighting_review_title": "Revisar preestablecido de iluminación", + "render.lighting_samples_cpu": "Las muestras de luz de área se aplican a ray tracing en CPU.", + "render.lighting_samples_need_area": "Añade una luz de área en CPU para ajustar muestras de área.", + "render.lighting_samples_reason": "Las muestras de área deben ser 1, 4, 16 o 64.", + "render.lighting_shadow_cpu": "El interruptor de sombras requiere ray tracing en CPU. El path tracing en GPU siempre proyecta sombras.", + "render.lighting_shadows": "Proyectar sombras", + "render.lighting_shadows_section": "Sombras y muestreo", + "render.lighting_shape": "Forma", + "render.lighting_solo": "Solo esta luz", + "render.lighting_source": "Fuente", + "render.lighting_source_section": "Fuente de luz", + "render.lighting_spot_preserved": "Esta luz puntual conserva su cono focal guardado hasta que se cambie su tipo.", + "render.lighting_sun_distance": "La dirección solar es independiente de la distancia y el punto de mira.", + "render.lighting_unsupported": "no admitido", + "render.lighting_used": "{used} de {limit}", + "render.lighting_width": "Ancho U", + "render.lighting_world_position": "Mundial X Y Z", "render.lights_fixed_to": "Luces fijadas a", "render.linear_help": "Conserva un máster RGBA de 32 bits en coma flotante antes de la reducción de ruido, exposición, mapeo tonal, filtros y fondo. sRGB lineal/D65, premultiplicado por cobertura. Requiere memoria adicional. Sin compresión ni pases extra.", "render.linear_retain": "Conservar EXR lineal sin procesar", "render.linear_unavailable": "La imagen terminada seleccionada no conserva EXR. Activa la opción antes de renderizar; no se puede recuperar la radiancia original de un PNG.", "render.look_add": "Añadir filtro de color", + "render.look_applied": "Aplicado {name}", "render.look_bleach": "Inspirado en bleach bypass", "render.look_cool": "Frío", "render.look_cyanotype": "Inspirado en cianotipia", "render.look_faded": "Copia desvanecida", + "render.look_material": "Material", "render.look_mono": "Monocromo", + "render.look_pasted": "Aspecto pegado", "render.look_sepia": "Sepia", "render.look_strength": "Intensidad del filtro", "render.look_teal_orange": "Verde azulado / naranja", @@ -2210,12 +2946,40 @@ "render.optics_save": "Guardar esta óptica en la toma", "render.optics_saved": "Objetivos ópticos guardados", "render.optics_saved_help": "La identidad exacta de definición, operación e instancia se conserva al reabrir sin conexión. Los objetivos ausentes, sustituidos u ocultos se rechazan; los materiales de ingeniería no cambian. Guarda o descarta los cambios numéricos antes de quitar un objetivo.", + "render.outer_half_angle": "Semángulo exterior", + "render.output_advanced": "Avanzado", + "render.output_backend_ready": "Listo", + "render.output_check": "Comprobar", + "render.output_check_failed": "Comprobar · error", + "render.output_check_help": "Ejecuta Comprobar para revisar esta petición sin iniciar un render.", + "render.output_check_summary": "Comprobar · Notas: {count}", + "render.output_copy": "Copiar", + "render.output_copy_report": "Copiar informe", + "render.output_destination": "Destino", + "render.output_destination_help": "Elige el archivo de destino al exportar la imagen completada.", + "render.output_dimensions": "Tamaño", + "render.output_effects": "Efectos", + "render.output_export": "Exportar PNG…", + "render.output_exr_unavailable": "La configuración de salida EXR no está disponible aquí. La exportación EXR lineal requiere una imagen GPU completada con máster lineal retenido.", + "render.output_fit": "Ajustar", + "render.output_format": "Formato", "render.output_format_status": "PNG es la imagen final exacta. El EXR opcional de GPU conserva radiancia lineal FLOAT32 antes de los filtros. La exportación no sustituye el resultado por la previsualización ni por otro trabajo.", + "render.output_grading": "Graduación", + "render.output_image": "Imagen", + "render.output_largest": "{width} × {height} máx.", "render.output_lock_ratio": "Bloquear proporción", "render.output_over_budget": "La imagen supera el presupuesto seleccionado. Elige explícitamente otro tamaño o perfil; no se reduce automáticamente.", + "render.output_presets_help": "Formato, calidad y la lista de Comprobar están en el editor de Salida.", + "render.output_quality": "Calidad", + "render.output_quality_waiting": "esperando el contrato de calidad", "render.output_ratio_refused": "La dimensión vinculada supera el límite del método. Se conserva el tamaño anterior; desbloquea la proporción o elige un valor menor.", + "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_ok": "Tamaño OK · {width} × {height} px", "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", "render.page_aging": "Envejecimiento y depósitos", "render.page_all": "Todos los controles", "render.page_appearance": "Aspecto", @@ -2228,6 +2992,7 @@ "render.page_output": "Salida", "render.page_scene": "Escena", "render.paint_label": "Pintar", + "render.painting_with": "Pintando con {name}", "render.passive_stage": "Suelo y escenario pasivo", "render.passive_stage_help": "Los paneles pasivos finitos solo pertenecen a las imágenes renderizadas. No añaden cuerpos CAD ni masa. La colocación queda fija tras aplicarlos; si se cruza con los límites del producto, se rechaza. La CPU no proporciona iluminación difusa rebotada.", "render.perspective": "Cámara en perspectiva", @@ -2243,9 +3008,13 @@ "render.physical_prepare_plan": "Preparar el plan de creación (no se aplica)", "render.physical_separate": "La apariencia no identifica el material. Son referencias con fuente, no propiedades efectivas certificadas. La masa, los materiales de ingeniería y la óptica existentes no cambian.", "render.physical_unknown": "No se ha establecido ningún candidato numérico. Hay que especificar el grado, la formulación o la construcción; desconocido no significa cero.", + "render.pick_body": "Cuerpo", "render.pick_component": "Componente", + "render.pick_face": "Cara", + "render.pick_help": "Elige Cuerpo, Cara o Componente. Haz clic en espacio vacío para limpiar; Mayús o Ctrl añade o alterna. Los destellos solo aparecen en la vista de producto en vivo.", "render.pick_label": "Seleccionar", "render.pick_up_label": "Tomar", + "render.pick_up_prompt": "Haz clic en un objeto para tomar su aspecto", "render.pick_waiting": "La selección de producto no está soportada aún.", "render.post_contrast": "Contraste", "render.post_gain_b": "Ganancia azul", @@ -2265,21 +3034,41 @@ "render.preview_upload": "Datos de vértices de la vista previa", "render.preview_upload_help": "Bytes exactos de los vértices de vista previa, no memoria total de GPU o proceso. No se simplifica la geometría. Los trabajos de imagen tienen admisión e iluminación propias.", "render.progressive_image_summary": "Vista previa PNG · PBR opaco · muestreo progresivo", + "render.property_conditions": "Condiciones: {value}", + "render.property_fallback": "propiedad", + "render.property_qualifier": "Calificador: {value}", + "render.property_source": "Fuente: {id} - {locator}", + "render.property_uncertainty": "Incertidumbre: {value}", + "render.property_unknown": "desconocido", "render.quality_ack": "Aceptar la proyección de presupuesto fijo y sus omisiones", "render.quality_apply": "Usar este presupuesto fijo", + "render.quality_balanced": "Equilibrado", + "render.quality_custom": "Personalizado", + "render.quality_draft": "Boceto", + "render.quality_high": "Alto", "render.quality_projection_help": "Los perfiles Q incluyen requisitos futuros. Esta acción proyecta solo un presupuesto PNG de muestras fijas y omite parada adaptativa, salida lineal y límites de transmisión independientes.", "render.quality_review": "Revisar presupuesto de muestras", "render.queue_pending": "Esta interfaz no contiene cola automática de producción ni renderizador de animaciones. Las imágenes terminadas se exportan por separado.", + "render.radial_assign": "Asignar Aspecto", + "render.radial_compare": "Comparar", "render.radial_distortion": "Distorsión radial", + "render.radial_fit": "Ajustar Producto", + "render.radial_save": "Guardar Toma", + "render.recent_looks": "Aspectos recientes", "render.regrade_action": "Reeditar máster bruto (sin reducción de ruido)", "render.regrade_help": "Crea un PNG independiente a partir del máster bruto del resultado seleccionado y estos controles de presentación. No traza rayos, cambia tamaño, cámara o luces capturados, ni sobrescribe el EXR. No vuelve a reducir el ruido: el máster no conserva las guías del filtro. El resultado se exporta mediante los controles habituales.", "render.regrade_label": "Regraduar", "render.reload_settings": "Recargar ajustes guardados", + "render.remove_override": "Eliminar Sobreescritura", + "render.replace_all": "Reemplazar Todo", "render.replace_ground": "Confirmar sustitución del escenario existente", "render.replace_label": "Reemplazar", "render.replace_saved_surface": "Sustituir una vinculación guardada de este destino", + "render.replace_with": "Reemplazar con", "render.restore_camera": "Restaurar cámara guardada", + "render.result_actual": "100%", "render.result_actual_help": "Un píxel de la imagen por cada píxel de la pantalla.", + "render.result_actual_size": "100%", "render.result_capture": "Revisión capturada", "render.result_current": "La revisión capturada del modelo está actualizada.", "render.result_empty": "Todavía no hay ninguna imagen terminada. Comprueba los ajustes e inicia un renderizado.", @@ -2327,14 +3116,29 @@ "render.saved_surface_remove": "Eliminar la vinculación guardada", "render.saved_surfaces": "Vinculaciones de superficie guardadas", "render.saved_surfaces_help": "Los mapas integrados fijados siguen a la instancia seleccionada en milímetros locales. Un destino sustituido o ausente es un error, nunca una reasignación automática.", + "render.scene_all_shots": "Todas las tomas ({count})", "render.scene_apply": "Aplicar estudio revisado", + "render.scene_batch_reason": "Todas las tomas necesita la cola de render de tomas guardadas, que no está disponible aún.", + "render.scene_check_now": "Comprobar ahora", + "render.scene_check_passed": "Comprobación superada · {notes} notas", + "render.scene_check_stale": "Comprobación desactualizada · comprobar de nuevo", + "render.scene_check_unchecked": "Sin comprobar aún · 0 notas", "render.scene_consent": "Confirmar sustitución de iluminación, entorno, exposición y escenario", + "render.scene_light_summary": "{environment} · {lights} luces", + "render.scene_look_summary": "{looks} aspectos · {overrides} sobreescrituras", + "render.scene_open_check": "Abrir Comprobar", "render.scene_preserved_fields": "Se conservan cámara, muestreo, dimensiones, asignaciones ópticas, geometría CAD y materiales físicos.", "render.scene_preserves_design": "Compón iluminación y escenario pasivo para el objeto visible. Geometría, materiales físicos y cámara se mantienen separados.", + "render.scene_ready": "Listo para renderizar", + "render.scene_reminder": "La escena preserva el diseño. La geometría, propiedades de ingeniería y CAD nunca cambian desde Render.", + "render.scene_reminders": "Recordatorios", + "render.scene_renderer_ready": "{renderer} listo", "render.scene_replaces": "Sustituye", "render.scene_review": "Resolver y revisar", "render.scene_review_help": "Revisa la composición nativa completa antes de aplicar. No se sustituyen las especificaciones SCENE avanzadas sin resolver.", "render.scene_scope_details": "Alcance de la composición", + "render.scene_stages": "Escenarios", + "render.search_appearances": "Buscar {count} aspectos", "render.setup_legacy_environment": "Esta configuración antigua usa rotación o intensidad de entorno heredadas. Se conservan esos valores; conviértelos explícitamente antes de guardar un trabajo de imagen completo.", "render.setup_name": "Nombre de configuración", "render.setup_numeric_help": "Guardar conserva el método de imagen activo y todos sus ajustes numéricos. Recargar o elegir otra configuración sustituye este borrador; la cámara se restaura por separado.", @@ -2344,8 +3148,24 @@ "render.sharpening": "Realce de detalle", "render.shell_tab_appearance": "APARIENCIA DE RENDER", "render.shell_tab_camera": "CÁMARA DE RENDER", + "render.shell_tab_lighting": "ILUMINACIÓN", "render.shell_tab_scene": "ESCENA DE RENDER", + "render.shot_aperture": "Apertura", + "render.shot_distance": "Distancia", + "render.shot_dof": "Profundidad de campo", "render.shot_duplicate": "Duplicar toma guardada", + "render.shot_edit_output": "Editar en Salida", + "render.shot_exposure_reason": "La exposición requiere ray tracing en CPU o path tracing en GPU.", + "render.shot_exposure_section": "Exposición", + "render.shot_focus_distance": "Distancia de foco", + "render.shot_fov_reason": "El campo de visión requiere proyección perspectiva.", + "render.shot_frame_subject": "Encuadrar sujeto", + "render.shot_invalid_camera": "Los valores de cámara deben ser finitos y estar en rango.", + "render.shot_lens": "Objetivo", + "render.shot_lens_enable_reason": "Activa profundidad de campo para editar apertura y distancia de foco.", + "render.shot_lens_gpu_reason": "La profundidad de campo necesita path tracing en GPU.", + "render.shot_lens_perspective_reason": "La profundidad de campo requiere proyección perspectiva.", + "render.shot_look_selection": "Mirar selección", "render.shot_manager": "Tomas guardadas", "render.shot_new_name": "Nombre de la nueva toma", "render.shot_remove": "Eliminar toma guardada", @@ -2353,18 +3173,129 @@ "render.shot_remove_title": "¿Eliminar toma guardada?", "render.shot_rename": "Renombrar toma guardada", "render.shot_saved_only": "Guarda primero el borrador. Duplicar y renombrar conservan los ajustes y las referencias de recursos.", + "render.shot_selection_reason": "Selecciona un cuerpo o cara para apuntar.", + "render.shot_size": "Tamaño", + "render.shot_target": "Objetivo", + "render.shots_camera_changed": "La cámara cambió desde que se guardó esta toma", + "render.shots_name_help": "Escribe un nombre único para la toma actual.", + "render.shots_no_result": "Sin render aún", + "render.shots_result": "Render {number} · {width} × {height}", + "render.shots_revert": "Revertir", + "render.shots_save": "Guardar Toma", + "render.shots_update": "Actualizar toma", "render.show_shots_label": "Mostrar tira de tomas", + "render.size_4k": "4K", + "render.size_4k_uhd": "4K UHD", + "render.size_custom": "Personalizado…", + "render.size_full_hd": "Full HD", + "render.size_hd": "HD", + "render.size_presets": "Preestablecidos de tamaño", + "render.size_presets_pending": "Llega con el editor de Salida", + "render.size_preview": "Previsualización", + "render.size_square": "Cuadrado", "render.sky_ground": "Suelo · RGB lineal", "render.sky_horizon": "Horizonte · RGB lineal", "render.sky_zenith": "Cénit · RGB lineal", "render.soft_focus": "Enfoque suave", + "render.spot_cone": "Cono focal", "render.stage_frame": "Fotograma", + "render.stage_layout.appearance_note": "Clic para aplicar a la selección.", + "render.stage_layout.studio_note": "Clic para revisar antes de aplicar.", "render.stage_light": "Luz", "render.stage_look": "Aspecto", "render.stage_orthographic": "Ortográfica", "render.stage_output": "Salida", "render.stage_panel_count": "Paneles guardados", + "render.stage_panels.acknowledge": "Reconozco el reemplazo y las limitaciones", + "render.stage_panels.appearance_note": "Clic para aplicar a la selección. Arrastrar sobre el modelo. Sin nada seleccionado, un clic arma Pintar.", + "render.stage_panels.apply": "Aplicar", + "render.stage_panels.apply_studio": "Aplicar estudio", + "render.stage_panels.badge_blocked": "bloqueado", + "render.stage_panels.badge_native": "nativo", + "render.stage_panels.badge_projection": "proyección", + "render.stage_panels.badge_reference": "referencia", + "render.stage_panels.badge_specification": "solo especificación", + "render.stage_panels.candidate_summary": "Candidatos: {count} valores origen. Revisar grado y acabado.", + "render.stage_panels.captured_bounds": "Usar límites de escena capturados", + "render.stage_panels.centre": "Centro", + "render.stage_panels.depth": "profundidad", + "render.stage_panels.environments": "Entornos", + "render.stage_panels.extent": "Extensión", + "render.stage_panels.fit_save": "Ajustar y guardar", + "render.stage_panels.gpu_required": "Requiere path tracing en GPU", + "render.stage_panels.keep": "Conservar", + "render.stage_panels.kept": "Conservado", + "render.stage_panels.kept_fields": "Cámara, muestreo, dimensiones, vinculaciones ópticas, geometría CAD y propiedades de ingeniería.", + "render.stage_panels.kept_quality": "Método, cámara, iluminación, entorno, escenario, ópticas, exposición y mapeo de tonos.", + "render.stage_panels.mine": "Míos", + "render.stage_panels.mine_pending": "Guardar estudios personales no se admite aún.", + "render.stage_panels.omits": "Omite", + "render.stage_panels.paint_pending": "Pintar y asignación por arrastre no se admiten aún. Elige un objetivo para aplicar un aspecto.", + "render.stage_panels.partial_reference": "Candidatos: solo valores origen; revisar grado, condiciones y fuentes. Valores desconocidos permanecen desconocidos.", + "render.stage_panels.plane": "Plano", + "render.stage_panels.prepare_plan": "Preparar plan de creación", + "render.stage_panels.projects": "Proyectos", + "render.stage_panels.quality_ack": "Reconozco el presupuesto fijo y sus omisiones", + "render.stage_panels.quality_omissions": "Parada adaptativa y objetivos de error, profundidad de transmisión separada, reglas de ruleta rusa, salida lineal bruta, máster EXR y certificación de convergencia.", + "render.stage_panels.reference": "Referencia", + "render.stage_panels.references": "Referencias", + "render.stage_panels.remove_optics": "Eliminar ópticas", + "render.stage_panels.remove_optics_help": "Elimina las ópticas guardadas de esta toma. La geometría de origen se conserva.", + "render.stage_panels.remove_optics_title": "¿Eliminar ópticas guardadas?", + "render.stage_panels.remove_shot": "Eliminar toma", + "render.stage_panels.remove_stage": "Eliminar escenario", + "render.stage_panels.remove_stage_help": "Elimina los paneles pasivos guardados de esta toma. El fondo de cámara se mantiene.", + "render.stage_panels.remove_stage_title": "¿Eliminar el escenario guardado?", + "render.stage_panels.repeat": "Repetir", + "render.stage_panels.replaces": "Reemplaza", + "render.stage_panels.replaces_environment": "Entorno", + "render.stage_panels.replaces_maps": "Mapas temporales en el cuerpo elegido; las ópticas guardadas pueden entrar en conflicto.", + "render.stage_panels.replaces_physical": "Nada. Este plan es de solo lectura.", + "render.stage_panels.replaces_quality": "Dimensiones de imagen, muestras fijas, profundidad de trayecto, semilla y denoising.", + "render.stage_panels.replaces_rig": "Iluminación, entorno y escenario", + "render.stage_panels.replaces_saved_maps": "Mapas de superficie guardados en el cuerpo elegido; las sobreescrituras temporales pueden entrar en conflicto.", + "render.stage_panels.replaces_stage": "Suelo guardado y paneles de escenario pasivos", + "render.stage_panels.replaces_studio": "Iluminación, entorno, exposición y escenario", + "render.stage_panels.replaces_uniform": "Aspecto opaco en el cuerpo elegido. Los mapas espaciales se aproximan por valores uniformes.", + "render.stage_panels.review_environment": "Revisar entorno", + "render.stage_panels.review_maps": "Revisar mapas GPU", + "render.stage_panels.review_physical": "Plan de referencia física", + "render.stage_panels.review_quality": "Usar presupuesto de muestreo fijo", + "render.stage_panels.review_rig": "Ajustar rig de luces", + "render.stage_panels.review_saved_maps": "Revisar mapas guardados", + "render.stage_panels.review_stage": "Revisar suelo y escenario", + "render.stage_panels.review_studio": "Revisar estudio nativo", + "render.stage_panels.review_uniform": "Revisar aspecto uniforme", + "render.stage_panels.rig_summary": "Rig de luces autoría · {emitters} emisores, {cards} tarjetas", + "render.stage_panels.rigs": "Rigs", + "render.stage_panels.save_maps": "Guardar mapas en esta toma", + "render.stage_panels.scope": "Ámbito", + "render.stage_panels.search_studios": "Buscar estudios", + "render.stage_panels.search_surfaces": "Buscar superficies y referencias", + "render.stage_panels.source_note": "Mapa de color base autoría. No es un material medido.", + "render.stage_panels.specifications": "Especificaciones · solo lectura", + "render.stage_panels.studio_ack": "Confirmar reemplazo de iluminación, entorno, exposición y escenario", + "render.stage_panels.studio_note": "Clic para revisar. Aplicar reemplaza iluminación, entorno y escenario solo tras confirmar. Las luces siguen editables.", + "render.stage_panels.studios": "Estudios", + "render.stage_panels.subtitle_environment": "Entorno HDR original · escena completa", + "render.stage_panels.subtitle_maps": "Mapas espaciales autoría · previsualización temporal", + "render.stage_panels.subtitle_physical": "Plan de solo lectura · no se crea ni asigna material", + "render.stage_panels.subtitle_quality": "Presupuesto GPU fijo · requisitos futuros omitidos", + "render.stage_panels.subtitle_rig": "Rig de luces autoría · emisores y tarjetas pasivas", + "render.stage_panels.subtitle_saved_maps": "Mapas autoría · guardados con esta toma", + "render.stage_panels.subtitle_stage": "Suelo físico y escenario · ajustado al producto", + "render.stage_panels.subtitle_studio": "Estudio nativo · se aplica a toda la escena", + "render.stage_panels.subtitle_uniform": "Aproximación uniforme · no es un material medido", + "render.stage_panels.surfaces": "Superficies", + "render.stage_panels.surfaces_references": "Superficies y referencias", + "render.stage_panels.target": "Objetivo", + "render.stage_panels.use_budget": "Usar este presupuesto", + "render.stage_panels.use_maps": "Usar mapas GPU", + "render.stage_panels.use_uniform": "Usar uniforme", "render.stage_preview_note": "Vista previa integrada · luz directa", + "render.state_completed": "Imagen completada", + "render.state_in_progress": "Imagen en curso", + "render.state_model": "Previsualización Modelo", "render.strip_label": "Tira", "render.studio_add_area": "Añadir luz de área", "render.studio_ambient": "Radiancia constante del entorno", @@ -2403,14 +3334,52 @@ "render.studio_sky_help": "Gradiente de cielo para iluminar la escena; no es un modelo atmosférico calibrado.", "render.studio_sun": "Sol", "render.studio_sun_radius": "Radio angular", + "render.studio_tiles_require_gpu": "Los estudios requieren path tracing en GPU. Selecciona GPU en el editor de Salida.", "render.studio_title": "Estudio de renderizado", "render.studio_tone_map": "Mapeo de tonos", + "render.studios_environments": "Estudios y Entornos", "render.subsurface_enable": "Recorrido aleatorio de dispersión interna", "render.subsurface_help": "Solo dieléctricos cerrados. Dispersión y absorción RGB son coeficientes separados por mm. Las trayectorias se dispersan dentro de la geometría y se refractan o reflejan en la frontera. Desactiva sombras rectas, reducción de ruido de superficies y volumen exterior; usa un estrato Fresnel. No es un filtro de difusión, rejilla de densidad ni material medido. Los medios densos pueden requerir más muestras y profundidad.", "render.subsurface_rate": "Tasa de colisiones de dispersión", + "render.tone_aces_fitted": "ACES ajustado", + "render.tone_reinhard": "Reinhard", "render.transmission_scope": "El vidrio GPU admite medios separados certificados. No se admiten medios anidados sin restricciones ni un estimador de transmisión sin ruido.", "render.transmission_strata": "Estratos de Fresnel condicionales", "render.transmission_strata_help": "1 conserva la secuencia original. 2 o 4 subtrayectorias estratifican la primera decisión de reflexión o transmisión del vidrio, sin cambiar Snell ni la absorción. Aumentan el trabajo; no son un filtro, soporte de medios anidados ni una garantía de mayor velocidad.", + "render.tree_add_light": "Añadir luz", + "render.tree_add_shot": "Añadir toma", + "render.tree_browser": "Explorador de Render", + "render.tree_by_look": "Por aspecto", + "render.tree_by_object": "Por objeto", + "render.tree_constant": "Entorno constante", + "render.tree_default": "Predeterminado", + "render.tree_directional": "Direccional", + "render.tree_environment": "Entorno", + "render.tree_environment_values": "{rotation}° · {intensity}×", + "render.tree_face_count": "{count} caras", + "render.tree_face_one": "{count} cara", + "render.tree_fixed_studio": "Estudio fijo", + "render.tree_hdr": "HDR", + "render.tree_lights": "Luces", + "render.tree_lights_pending": "Los comandos de luz nombrados no están disponibles aún.", + "render.tree_mixed": "Mixto", + "render.tree_more": "{count} más", + "render.tree_mute": "Silenciar luz", + "render.tree_no_environment": "Sin entorno", + "render.tree_objects": "Objetos", + "render.tree_output": "Salida", + "render.tree_point": "Puntual", + "render.tree_rectangle": "Rectángulo", + "render.tree_search": "Buscar objetos", + "render.tree_selection": "Selección de render", + "render.tree_shots": "Tomas", + "render.tree_sky": "Cielo", + "render.tree_solo": "Solo luz", + "render.tree_spot": "Focal", + "render.tree_sun": "Solar", + "render.tree_switch": "Cambiar", + "render.tree_user_one": "{count} objeto", + "render.tree_users": "{count} objetos", "render.unavailable_preview": "Esta configuración solicita un perfil de vista previa no disponible.", "render.unsaved": "Cambios de vista previa sin guardar", "render.use_preview": "Usar vista previa integrada", @@ -2422,12 +3391,20 @@ "render.volume_max": "Límites máximos · mm globales", "render.volume_min": "Límites mínimos · mm globales", "render.volume_title": "Volumen homogéneo acotado", + "render.wait_add_light": "no admitido aún: creación de luz nombrada", "render.wait_batch": "no soportado aún: la cola de renderizado de tomas guardadas", "render.wait_compare": "no soportado aún: comparación de imágenes retenidas", "render.wait_paint": "no soportado aún: selección de producto y pintura de apariencias", "render.wait_pick_up": "no soportado aún: selección de producto y toma de apariencias", "render.wait_replace": "no soportado aún: reemplazar todos los usos de una apariencia", "render.wait_solo": "no soportado aún: visibilidad y solo de luces", + "render.wear_applies_to": "Se aplica a", + "render.wear_layer_count": "{count} capas", + "render.wear_layer_one": "{count} capa", + "render.wear_layers": "Capas", + "render.wear_no_layers": "Sin capas", + "render.wear_selected_layer": "Capa seleccionada", + "render.wear_title": "Desgaste", "sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm", "sample.bracket.name": "Soporte de chapa", "sample.enclosure.about": "Caja vaciada con labio, salientes para tornillos, apoyo y encaje a presión", @@ -2461,6 +3438,7 @@ "select an axis": "selecciona un eje", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "selecciona primero un eje, una arista recta o circular, o una cara cilíndrica, cónica o toroidal", "select bodies first": "selecciona primero cuerpos", + "select components first": "selecciona componentes primero", "select corner points, or the two lines of one corner": "selecciona puntos de esquina, o las dos líneas de una esquina", "select one corner point, or the two lines of one corner": "selecciona un punto de esquina, o las dos líneas de una esquina", "select sheet edges first": "selecciona primero aristas de chapa", @@ -2476,8 +3454,10 @@ "select two components": "selecciona dos componentes", "set by the sketch's lines": "definido por las líneas del boceto", "settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.", + "settings.double_click_components": "Doble clic en un componente para activarlo, o para editar uno vinculado en contexto", "settings.frame_rate_details_note": "Añade un gráfico con los tiempos de los últimos fotogramas y los triángulos dibujados encima, un histograma de tiempos de fotograma y lo que dibujó la GPU. Haz clic en la tasa de fotogramas de la vista para mostrarlos u ocultarlos.", "settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista.", + "settings.insert_lists_open_designs": "Insertar Diseño lista primero los diseños abiertos en otras pestañas", "settings.measure_axis_distances": "Mostrar distancias X/Y/Z", "settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.", "settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft", @@ -2488,6 +3468,7 @@ "settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar SolidCraft, por lo que se han cambiado:", "settings.reset_title": "Se han cambiado los ajustes", "settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.", + "settings.update_changed_links": "Actualizar diseños vinculados cambiados al volver a la pestaña", "settings.window_system_details": "Detalles", "settings.window_system_note_now": "Se aplica tras reiniciar. Ahora: {now}.", "settings.window_system_wayland_limited": "Algunas funciones tienen limitaciones en Wayland nativo.", @@ -2501,6 +3482,10 @@ "shortcut.unpin": "Desfijar", "sketch is missing": "falta el boceto", "sketch.degrees_of_freedom": "{count} grados de libertad", + "sketch.dimension_added_driven": "El boceto ya determina esa cota, así que se añadió como cota dependiente (de referencia)", + "sketch.dimension_applied": "Cota aplicada", + "sketch.dimension_applied_defined": "Cota aplicada; parámetro `{name}` definido", + "sketch.dimension_applied_renamed": "Cota aplicada; parámetro nombrado `{name}`", "sketch.dof_left": "Quedan {count} grados de libertad", "sketch.dof_left_one": "Queda {count} grado de libertad", "sketch.draw_curves": "Dibujar como {linetype}", @@ -2534,8 +3519,12 @@ "sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva", "sketch_text.use_font_file": "Usar el archivo de fuente {path}", "solid": "sólido", + "status.editing": "Editando {name}", "status.navigation": "Asignación del ratón: {mapping}", "status.navigation_tip": "Cómo mueve el ratón la vista. Haz clic para elegir otra asignación", + "status.under_constrained": "Bajo restricción ({count} grados de libertad)", + "status.under_constrained_one": "Bajo restricción ({count} grado de libertad)", + "status.units_tip": "Las unidades del diseño. Haz clic para cambiarlas", "the angle must be a number": "el ángulo debe ser un número", "the curves must be in one sketch": "las curvas deben estar en un solo boceto", "the direction must be an axis, a sketch line or a straight edge": "la dirección debe ser un eje, una línea de boceto o una arista recta", @@ -2566,16 +3555,20 @@ "timeline.delete_folders_tip": "Conservar sus operaciones y carpetas anidadas en el historial", "timeline.delete_folders_with_features_tip": "Elimina las carpetas con todas sus operaciones y sus carpetas anidadas", "timeline.delete_many": "Eliminar {count} operaciones seleccionadas", + "timeline.edited_rolled_back": "{name} cambiado, pero está después del marcador del historial, así que no se construye: mueve el marcador pasado para ver el resultado", "timeline.feature_error": "Error: {message}", "timeline.feature_hint": "Doble clic para editar · arrastra para reordenar · clic derecho para ver más opciones", + "timeline.feature_rolled_back": "Retrocedido: después del marcador del historial, así que no se construye", "timeline.feature_warning": "Advertencia: {message}", "timeline.group": "Nueva carpeta de operaciones", "timeline.move_many": "Mover elementos de la línea de tiempo", "timeline.move_to_group": "Mover operaciones a la carpeta", "timeline.rename_group": "Cambiar nombre de carpeta de operaciones", "timeline.reorder_many": "Reordenar operaciones", + "timeline.suppress_features": "Desactivar operaciones", "timeline.suppress_folders_tip": "Desactiva o reactiva todas las operaciones de las carpetas y sus carpetas anidadas", "timeline.ungroup": "Quitar carpetas de operaciones", + "timeline.unsuppress_features": "Reactivar operaciones", "to the face": "a la cara", "to the next face": "hasta la siguiente cara", "toolbar.customize_note": "Pestaña {tab}. Marque un comando para añadir su botón a la barra de herramientas. También puede arrastrar una fila del desplegable de un panel a la barra, arrastrar un botón fuera de la barra para quitarlo o usar la chincheta de una fila del desplegable.", @@ -2647,6 +3640,10 @@ "ui.3d_mouse_offer": "Calibrar tu ratón 3D corrige los ejes intercambiados o invertidos y ajusta su velocidad y sensación.", "ui.3d_mouse_offer_never": "No volver a mostrar", "ui.3d_mouse_offer_swap": "Cambiar a Hidraw (controlador directo)", + "ui.3d_mouse_orbit": "Órbita", + "ui.3d_mouse_orbit_classic": "Clásica", + "ui.3d_mouse_orbit_tip": "Rastreada: el ratón 3D gira la vista alrededor de la parte del modelo en el centro de la vista al empezar el giro, marcada por una bola tenue. Clásica: alrededor del centro de la vista", + "ui.3d_mouse_orbit_traced": "Rastreada", "ui.3d_mouse_reading": "Lectura", "ui.3d_mouse_reset_axes": "Restablecer asignación de ejes", "ui.3d_mouse_reset_view": "Restablecer vista", @@ -2683,6 +3680,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.", "ui.3d_mouse_switch_cap": "Calibrar como retorno al centro", "ui.3d_mouse_switch_trackball": "Calibrar como trackball", + "ui.3d_mouse_use_direct": "Usar controlador directo", + "ui.3d_mouse_use_direct_tip": "Leer el ratón 3D directamente (hidraw) en lugar de a través de spacenavd.", "ui.3d_mouse_view_axis": "Eje de la vista", "ui.3d_mouse_view_off": "Desactivado", "ui.3d_mouse_view_off_tip": "El perfil del controlador del ratón 3D desactiva este eje, así que su movimiento no mueve la vista. Si eliges aquí el movimiento de otro eje, se intercambian y ese movimiento deja de mover la vista.", @@ -2974,6 +3973,9 @@ "ui.blocked": "Bloqueado", "ui.bodies": "Cuerpos", "ui.bodies_all_if_none": "Cuerpos (todos si no hay ninguno)", + "ui.body_kind_mesh": "Cuerpo de malla", + "ui.body_kind_solid": "Cuerpo sólido", + "ui.body_kind_surface": "Cuerpo de superficie", "ui.bottom": "Inferior", "ui.box": "Caja", "ui.break": "Romper", @@ -2986,6 +3988,8 @@ "ui.cancel": "Cancelar", "ui.canvases": "Lienzos", "ui.center": "Centro", + "ui.center_arc_follows_cursor": "Arco por centro sigue la dirección del cursor", + "ui.center_arc_follows_cursor_tip": "Un arco por centro va en el sentido de las agujas del reloj o contrario según el cursor rodea su centro. Desactivado: siempre contrario a las agujas del reloj desde el inicio.", "ui.centerline": "Línea de centro", "ui.chain": "Cadena", "ui.chamfer": "Chaflán", @@ -2994,6 +3998,7 @@ "ui.circle": "Círculo", "ui.circular_pattern": "Patrón circular", "ui.clear": "Borrar", + "ui.clear_print_face": "Limpiar cara de impresión", "ui.clear_recent": "Borrar recientes", "ui.clear_the_selection": "Borrar la selección", "ui.click_selects_whole_bodies": "Un clic selecciona cuerpos completos", @@ -3028,6 +4033,15 @@ "ui.constant": "Constante", "ui.constraints_conflict": "Conflicto entre restricciones", "ui.construction": "Construcción", + "ui.construction.angle_hint": "Línea de construcción: haz clic en un punto, luego escribe un ángulo o elige una dirección", + "ui.construction.bisector_hint": "Bisectriz: elige dos líneas rectas que se intersecten, luego la solución deseada", + "ui.construction.common_hint": "Tangente común: elige dos círculos/arcos, luego la solución deseada", + "ui.construction.contact_hint": "Tangente: elige un círculo/arco o spline/cónica abierta, luego el punto de contacto (libre)", + "ui.construction.external_hint": "Tangente: elige un círculo o arco, un punto externo, luego la solución deseada", + "ui.construction.free_hint": "Línea de construcción: haz clic en dos puntos; clic derecho para reiniciar", + "ui.construction.point_hint": "Línea de construcción: haz clic en el punto por el que debe pasar", + "ui.construction.reference_hint": "Línea de construcción: elige una línea recta, luego un punto; clic derecho para cambiar la", + "ui.construction.ribbon_label": "X-Líneas", "ui.context_menu": "Menú contextual", "ui.continuity_g0": "G0 arista", "ui.continuity_g1": "G1 tangencia", @@ -3094,6 +4108,24 @@ "ui.easyprint_uploading": "Subiendo a Printables…", "ui.edit": "Editar", "ui.editJointLimits": "Editar límites de unión", + "ui.edit_in_place": "Editar en contexto", + "ui.edit_in_place_after_turn": "La edición finaliza cuando termina el turno del agente", + "ui.edit_in_place_agent_busy": "El agente sigue trabajando en este diseño; inténtalo de nuevo cuando termine su turno", + "ui.edit_in_place_already_queued": "Ya hay una edición esperando al final del turno del agente", + "ui.edit_in_place_assembly_closed": "{name} ya no está abierto", + "ui.edit_in_place_banner": "Editando en {name}", + "ui.edit_in_place_busy": "Finaliza o cancela lo que esté abierto primero", + "ui.edit_in_place_none": "Ningún diseño vinculado se edita en contexto", + "ui.edit_in_place_not_saved": "La pieza tiene cambios sin guardar; guárdala para finalizar la edición", + "ui.edit_in_place_open_elsewhere": "{name} está abierto en otro SolidCraft", + "ui.edit_in_place_opens_after_turn": "La pieza se abre cuando termina el turno del agente", + "ui.edit_in_place_refresh_dropped": "El ensamblaje cambió mientras se ejecutaba Actualizar vínculos; ejecútalo de nuevo", + "ui.edit_in_place_refresh_stopped": "Actualizar vínculos se detuvo antes de terminar", + "ui.edit_in_place_refreshing": "Actualizando vínculos en {name}…", + "ui.edit_in_place_save_first": "Guarda el ensamblaje primero: sus diseños vinculados se encuentran desde su archivo", + "ui.edit_in_place_save_waits": "Actualizar vínculos se está ejecutando en {name}; guárdalo cuando termine", + "ui.edit_in_place_saved_elsewhere": "La pieza se guardó en otro archivo; el ensamblaje sigue vinculando {name}", + "ui.edit_in_place_wait": "Espera a que termine lo que se está ejecutando en el diseño", "ui.edit_section_analysis": "Editar análisis de sección", "ui.ellipse": "Elipse", "ui.emboss_wrap_note": "Los perfiles de líneas ortogonales tangentes a un cilindro compatible se envuelven automáticamente. Elegir una pared para definir el destino.", @@ -3101,6 +4133,14 @@ "ui.end_scale": "Escala final", "ui.entities": "Entidades", "ui.equal": "Igual", + "ui.equation_end": "Fin", + "ui.equation_explicit": "Explícita", + "ui.equation_form": "Forma", + "ui.equation_note": "La variable es un número simple: sen(t) la toma en radianes. Un resultado sin unidad está en la unidad del diseño.", + "ui.equation_parametric": "Paramétrica", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Radio equivalente", "ui.expand": "Expandir", "ui.exportDxf": "Exportar DXF…", @@ -3166,8 +4206,10 @@ "ui.heading_design": "DISEÑO", "ui.heading_draft": "ÁNGULO DE SALIDA", "ui.heading_edit": "EDITAR", + "ui.heading_equation_curve": "CURVA ECUACIÓN", "ui.heading_extrude": "EXTRUIR", "ui.heading_fillet": "REDONDEO", + "ui.heading_finish_editing": "FINALIZAR EDICIÓN", "ui.heading_finish_sketch": "FINALIZAR BOCETO", "ui.heading_form": "FORMA", "ui.heading_front": "FRONTAL", @@ -3238,6 +4280,10 @@ "ui.history_marker_tip": "Marcador del historial: arrastra para retroceder el modelo; las nuevas operaciones se insertan aquí", "ui.history_names": "Nombres", "ui.history_play": "Reproducir hasta el final", + "ui.history_play_button_hide": "Ocultar botón Reproducir", + "ui.history_play_button_show": "Mostrar botón Reproducir", + "ui.history_play_finish_action": "Finaliza la acción actual antes de reproducir el Historial", + "ui.history_play_roll_back": "Mueve el marcador del Historial atrás antes de reproducir", "ui.history_show_names": "Mostrar nombres", "ui.history_sketch_listed_under": "Listado bajo {feature}", "ui.history_title": "Historial", @@ -3252,6 +4298,10 @@ "ui.import_ellipsis": "Importar…", "ui.infinite": "Infinito", "ui.insert": "Insertar", + "ui.insert_design.browse": "Examinar…", + "ui.insert_design.open_designs": "Diseños abiertos", + "ui.insert_design.saved_file": "{name} tiene cambios sin guardar, así que se insertó su archivo guardado. Guárdalo y luego Actualizar vínculos para traer los cambios.", + "ui.insert_design.unsaved": "Cambios sin guardar", "ui.inspect": "Inspeccionar", "ui.intersect": "Intersecar", "ui.isolate": "Aislar", @@ -3366,6 +4416,7 @@ "ui.pinToShortcuts": "Fijar en accesos directos", "ui.pinToToolbar": "Fijar en barra de herramientas", "ui.pipe": "Tubo", + "ui.pitch": "Inclinación", "ui.placement": "Colocación", "ui.placement_handles": "Tiradores de colocación", "ui.placement_handles_tip": "Tiradores en la vista previa de un Refuerzo, Saliente o Encaje a presión: arrastra el marcador para moverlo por el modelo, gíralo por su anillo.", @@ -3380,17 +4431,48 @@ "ui.plugins_skipped": "No se pudieron leer {count} carpeta(s).", "ui.plugins_version": "{version}", "ui.point": "Punto", + "ui.point_cloud_add_filter": "Añadir filtro…", + "ui.point_cloud_auto_range": "Rango automático", + "ui.point_cloud_color_by": "Color por", + "ui.point_cloud_file_colors": "Colores del archivo", + "ui.point_cloud_filter_by": "Filtrar por", + "ui.point_cloud_height_high": "Más alto", + "ui.point_cloud_height_low": "Más bajo", + "ui.point_cloud_legend_filter": "Mostrados y ocultos en la leyenda de arriba", + "ui.point_cloud_no_file_colors": "El archivo no tiene colores", + "ui.point_cloud_pause": "Pausa", + "ui.point_cloud_play": "Reproducir", + "ui.point_cloud_single_color": "Color único", + "ui.point_cloud_size": "Tamaño de punto", + "ui.point_cloud_units": "Unidades del archivo", "ui.point_under_the_cursor": "Punto bajo el cursor", "ui.points": "Puntos", "ui.polygon": "Polígono", "ui.preferences": "Preferencias", "ui.press_a_key": "pulsa una tecla…", + "ui.print_face_cleared": "{body}: cara de impresión limpiada", + "ui.print_face_input": "Cara de impresión", + "ui.print_face_one_body": "Se ha elegido una cara de impresión para un cuerpo: envía ese cuerpo solo para cambiarla", + "ui.print_face_pick": "Selecciona una cara plana de un cuerpo sólido para hacerla la cara de impresión", + "ui.print_face_set": "{body}: cara de impresión establecida; Enviar a Laminador coloca el cuerpo sobre ella", "ui.profile": "Perfil", "ui.profile_plane": "Plano de perfil", "ui.profiles": "Perfiles", "ui.project": "Proyecto", "ui.project_face_edges_sketch": "Proyectar aristas de caras en bocetos nuevos", "ui.properties": "Propiedades", + "ui.properties.copy_matrix": "Copiar matriz", + "ui.properties.first_moments": "Primeros momentos", + "ui.properties.inertia": "Momento de inercia", + "ui.properties.inertia_note": "Respecto al centro de masas, en ejes mundiales (g·{unit}²)", + "ui.properties.inertia_unavailable": "No disponible: la malla de un cuerpo no cierra.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Midiendo…", + "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", "ui.pull_direction": "Dirección de tracción", "ui.quality": "Calidad", "ui.quantity": "Cantidad", @@ -3427,11 +4509,16 @@ "ui.restore_defaults": "Restaurar valores predeterminados", "ui.reverse_horizontal_pan_direction": "Invertir dirección del desplazamiento horizontal", "ui.reverse_horizontal_pan_tip": "El desplazamiento horizontal con dos dedos y la rueda de pulgar desplazan en sentido contrario", + "ui.reverse_trackpad_orbit_direction": "Invertir dirección de órbita en panel táctil", + "ui.reverse_trackpad_orbit_tip": "Mayús con scroll de dos dedos orbita en sentido contrario", + "ui.reverse_vertical_pan_direction": "Invertir dirección de desplazamiento vertical", + "ui.reverse_vertical_pan_tip": "El scroll vertical de dos dedos desplaza en sentido contrario", "ui.reverse_zoom_direction": "Invertir dirección del zoom", "ui.revolutions": "Vueltas", "ui.revolve": "Revolución", "ui.ribbon_exit": "Salir", "ui.ribbon_labels": "Mostrar texto bajo los iconos de la cinta", + "ui.ribbon_menus_skip_pinned": "Dejar comandos anclados fuera de los menús desplegables", "ui.ribbon_pin_footer": "Los comandos anclados se muestran en la cinta. Pasa el ratón sobre una fila para anclarla o desanclarla.", "ui.ribbon_workspace": "Espacio de trabajo", "ui.right": "Derecha", @@ -3486,7 +4573,16 @@ "ui.sketch": "Boceto", "ui.sketch_dimension": "Cota de boceto", "ui.sketch_grid": "Cuadrícula de boceto", + "ui.sketch_intersect_geometry": "Geometría", + "ui.sketch_intersect_projection_link": "Vinculación de proyección", + "ui.sketch_intersect_selection_filter": "Filtro de selección", "ui.sketch_modifiers": "Modificadores del boceto", + "ui.sketch_project_geometry": "Geometría", + "ui.sketch_report.all_constrained": "Todo está totalmente restringido.", + "ui.sketch_report.dof": "Grados de libertad", + "ui.sketch_report.loose": "No totalmente restringido", + "ui.sketch_report.status": "Estado", + "ui.sketch_report.title": "Informe de boceto", "ui.sketch_scale": "Escala del boceto", "ui.sketches": "Bocetos", "ui.slice": "Laminar", @@ -3537,6 +4633,7 @@ "ui.tab_manage": "Gestionar", "ui.tab_sheet_metal": "Chapa", "ui.tab_view": "Vista", + "ui.tab_weldments": "Soldaduras", "ui.tangent": "Tangente", "ui.taper_angle": "Ángulo de conicidad", "ui.tapped": "Roscado", @@ -3552,6 +4649,7 @@ "ui.tip_angle": "Ángulo de punta", "ui.to_object": "Hasta objeto", "ui.tool_direction": "Dirección de herramienta", + "ui.toolbar_tabs_hidden": "Todas las pestañas de la barra de herramientas están ocultas. Haz clic en + o clic derecho aquí para mostrar una.", "ui.top": "Superior", "ui.top_diameter": "Diámetro superior", "ui.top_radius": "Radio superior", @@ -3577,12 +4675,30 @@ "ui.updateToCurrentView": "Actualizar a vista actual", "ui.upload": "Subir", "ui.vertical_constraint": "Restricción vertical", + "ui.view_angles": "Ángulos de vista", + "ui.view_angles_convention": "Grados sobre el eje vertical. Rumbo 0° es la vista Frontal, 90° Izquierda, −90° Derecha, 180° Trasera; inclinación 90° es la vista Superior; un giro positivo rota el modelo en el sentido de las agujas del reloj.", + "ui.view_angles_help": "Muestra la inclinación, rumbo y giro de la vista en grados y su proyección bajo el gizmo de orientación. Haz clic en la lectura para escribir ángulos exactos.", + "ui.view_angles_ortho": "Ortogonal", + "ui.view_angles_persp": "Perspectiva", + "ui.view_angles_pitch": "Inclinación", + "ui.view_angles_readout": "Inclinación {pitch} · Rumbo {yaw} · Giro {roll}", + "ui.view_angles_roll": "Giro", + "ui.view_angles_yaw": "Rumbo", "ui.view_centre": "Centro de la vista", + "ui.viewport_status_clear": "Desactivar {name}", + "ui.viewport_status_clear_hint": "Desactivar {name} · Esc", + "ui.viewport_status_edges": "{count} aristas", + "ui.viewport_status_from": "desde {direction}", + "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_stripes": "{count} franjas", "ui.viewport_theme_follow": "Seguir tema", "ui.visual_shaded_hidden_edges": "Sombreado con aristas ocultas", "ui.visual_shaded_with_edges": "Sombreado con aristas", "ui.visual_style": "Estilo visual", "ui.visual_wireframe": "Estructura alámbrica", + "ui.wheel_zoom_speed": "Velocidad de zoom con rueda del ratón", "ui.width": "Anchura", "ui.window_system": "Sistema de ventanas", "ui.window_system_auto": "Automático", @@ -3613,7 +4729,9 @@ "update.button": "Actualizar", "update.button_version": "Actualizar a {version}", "update.check_automatically": "Buscar actualizaciones automáticamente", + "update.check_for_updates": "Buscar actualizaciones", "update.check_now": "Comprobar ahora", + "update.check_unavailable": "Las comprobaciones de actualización no están disponibles para esta instalación.", "update.checking": "Comprobando…", "update.copy": "Copiar", "update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra SolidCraft a Aplicaciones, sustituye la copia anterior y reinicia SolidCraft.", @@ -3627,13 +4745,18 @@ "update.installed": "SolidCraft {version} está instalado. Reinicia para utilizarlo.", "update.installer_started": "El instalador se iniciará cuando se cierre SolidCraft.", "update.later": "Recordármelo más tarde", + "update.managed": "Actualización disponible: actualiza SolidCraft en {manager}", "update.no_notes": "Esta versión no tiene notas.", "update.package_hint": "Esta copia procede de {kind}. Descarga el paquete nuevo e instálalo con:", "update.prefs_dev": "Esta es una compilación de desarrollo ({version}): no busca actualizaciones.", "update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. SolidCraft no lo comprueba.", "update.prefs_label": "Actualizaciones", + "update.prefs_managed": "Este es SolidCraft {version}, gestionado por {manager}. Una comprobación pide a GitHub la última versión y no envía nada más; actualiza SolidCraft en {manager}.", "update.prefs_note": "Esta es la versión {version} de SolidCraft ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.", "update.prefs_web": "Recarga la página para obtener la última aplicación web. Si la red no está disponible o la actualización no termina de descargarse a tiempo, se abrirá la versión guardada; una descarga en curso continúa para la próxima recarga.", + "update.refresh_checking": "Comprobando la versión más reciente…", + "update.refresh_fallback": "No se ha podido comprobar una versión más reciente; actualizando a {version}, encontrada antes.", + "update.refresh_selected": "Actualizando a {version}.", "update.release_page": "Abrir página de la versión", "update.restart": "Reiniciar ahora", "update.restart_later": "Más tarde", @@ -3648,6 +4771,7 @@ "values must be finite numbers": "los valores deben ser números finitos", "values must be numbers or expressions": "los valores deben ser números o expresiones", "view.auto_ortho_faces": "Perspectiva al orbitar, orto en caras", + "view.render_inspector": "Inspector de render", "web.files.insert_drawings_hint": "Archivos CAD, mallas, DXF o SVG en el diseño abierto", "web.files.insert_stored_drawings_hint": "En el diseño abierto (archivos CAD, mallas, DXF, SVG)", "web.open_full": "Abrir en SolidCraft", diff --git a/assets/locales/fi.json b/assets/locales/fi.json new file mode 100644 index 0000000000..3197471228 --- /dev/null +++ b/assets/locales/fi.json @@ -0,0 +1,4803 @@ +{ + "(active rule)": "(aktiivinen sääntö)", + "0 turns it off": "0 poistaa sen käytöstä", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Avaa alla oleva GitHub-luonnos ja liitä sitten leikepöydälle kopioitu raportti.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Avaa alla oleva GitHub-luonnos. Otsikkosi ja raporttisi täytetään valmiiksi.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Liitä kuvakaappaus: kopioi ja liitä se tai tallenna ja lataa PNG-tiedosto.", + "2D": "2D", + "3. Click Submit new issue on GitHub to send your report.": "3. Lähetä raportti napsauttamalla GitHubissa Submit new issue.", + "A Single hole snaps to the middle of its face and to the centres of its round edges": "Yksittäinen reikä tarttuu pintansa keskelle ja pyöreiden särmiensä keskipisteisiin", + "A new configuration, a copy of the active one": "Uusi konfiguraatio, kopio aktiivisesta", + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "Sijoitetulla yksittäisellä reiällä on vetokahva, ja sen pinnan valitseminen uudelleen siirtää reiän sinne sen sijaan, että reikä poistettaisiin", + "A short title for the problem (optional)": "Ongelman lyhyt otsikko (valinnainen)", + "ARRANGE": "JÄRJESTÄ", + "About SolidCraft": "Tietoja SolidCraftista", + "Acceleration": "Kiihtyvyys", + "Access": "Käyttöoikeus", + "Actions": "Toiminnot", + "Active": "Aktiivinen", + "Active rule": "Aktiivinen sääntö", + "Add": "Lisää", + "Add Column": "Lisää sarake", + "Add Key": "Lisää avain", + "Add Mid Radius": "Lisää keskisäde", + "Add Row": "Lisää rivi", + "Add a tool": "Lisää työkalu", + "Add all bodies": "Lisää kaikki kappaleet", + "Add bend": "Lisää taivutus", + "Add condition": "Lisää ehto", + "Add or edit rule": "Lisää tai muokkaa sääntöä", + "Add to New Folder": "Lisää uuteen kansioon", + "Add visible bodies": "Lisää näkyvät kappaleet", + "Advanced": "Lisäasetukset", + "Against": "Vastakkain", + "Alignment": "Tasaus", + "All Lines": "Kaikki viivat", + "All Sketch Points": "Kaikki luonnospisteet", + "All components": "Kaikki komponentit", + "All open designs": "Kaikki avoimet mallit", + "Allowed expressions, separated by semicolons": "Sallitut lausekkeet puolipisteillä erotettuina", + "Also deletes": "Poistaa myös", + "Also place them from the origin (fully constrained)": "Sijoita ne myös origosta (täysin määritelty)", + "Anchor": "Ankkuri", + "Angle 2": "Kulma 2", + "Angle Display": "Kulman näyttö", + "Animate": "Animoi", + "Another bend on the far edge of the last flange": "Uusi taivutus viimeisen laipan kauempaan särmään", + "App/system information and command settings are included. Review private details before sharing.": "Mukana ovat sovellus- ja järjestelmätiedot sekä komentoasetukset. Tarkista yksityiset tiedot ennen jakamista.", + "Appearance name": "Ulkoasun nimi", + "Applied Radius": "Käytetty säde", + "Apply To": "Kohdista", + "Apply library appearance": "Käytä kirjaston ulkoasua", + "Apply to selection": "Käytä valintaan", + "Apply your appearance first. Exports assigned appearances and materials created in this design.": "Käytä ulkoasua ensin. Vie määritetyt ulkoasut ja tässä mallissa luodut materiaalit.", + "Arc length": "Kaaren pituus", + "Area": "Pinta-ala", + "As Picked": "Valinnan mukaan", + "As-Built": "Nykyasennon mukainen", + "Assign Rule": "Määritä sääntö", + "Attachment": "Kiinnitys", + "BLEND SURFACE": "SULAUTUSPINTA", + "BOUNDARY SURFACE": "REUNAPINTA", + "BROWSER": "SELAIN", + "Ball": "Pallo", + "Bar": "Tanko", + "Base Center": "Pohjan keskipiste", + "Base Height": "Pohjan korkeus", + "Bend": "Taivutus", + "Bend Edges": "Taivutussärmät", + "Bend Line": "Taivutusviiva", + "Bend Line Position": "Taivutusviivan sijainti", + "Bend Position": "Taivutuksen sijainti", + "Bend Radius": "Taivutussäde", + "Bend Table": "Taivutustaulukko", + "Bends": "Taivutukset", + "Bodies": "Kappaleet", + "Bodies (optional)": "Kappaleet (valinnainen)", + "Body": "Kappale", + "Body (last if none)": "Kappale (viimeinen, jos ei valittu)", + "Body to split": "Jaettava kappale", + "Body, or faces to take out": "Kappale tai poistettavat pinnat", + "Both": "Molemmat", + "Bottom Left": "Alhaalla vasemmalla", + "Bottom Middle": "Alhaalla keskellä", + "Bottom Right": "Alhaalla oikealla", + "Boundary": "Reuna", + "Bounding box": "Rajauslaatikko", + "CHECK": "TARKISTA", + "COMPONENTS": "KOMPONENTIT", + "CONFIGURATIONS": "KONFIGURAATIOT", + "CONSTRAIN": "RAJOITA", + "CONTACT": "KOSKETUS", + "CONVERT": "MUUNNA", + "CSV or JSON: updates existing parameters, adds new ones": "CSV tai JSON: päivittää olemassa olevat parametrit ja lisää uudet", + "Capture All Positions": "Tallenna kaikki asennot", + "Capture Pos.": "Tallenna as.", + "Catch Depth": "Salvan syvyys", + "Catch Direction": "Salvan suunta", + "Catch Length": "Salvan pituus", + "Cells": "Solut", + "Center X": "Keskipiste X", + "Center Y": "Keskipiste Y", + "Center of mass": "Massakeskipiste", + "Centre to centre": "Keskeltä keskelle", + "Centroid": "Pintakeskiö", + "Chamfer Type": "Viisteen tyyppi", + "Channel": "U-profiili", + "Check collisions": "Tarkista törmäykset", + "Checked": "Valittu", + "Choose Variable radius or Spline in Type.": "Valitse Tyyppi-kohdasta Muuttuva säde tai Splini.", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Valitse uusi taso. Luonnoksen geometria ja mitat säilyvät ennallaan sen kaksiulotteisissa koordinaateissa.", + "Choose…": "Valitse…", + "Circle center": "Ympyrän keskipiste", + "Circle or sphere": "Ympyrä tai pallo", + "Circular": "Ympyrä", + "Circumference": "Kehä", + "Clockwise": "Myötäpäivään", + "Close draft": "Sulje luonnos", + "Collapse": "Kutista", + "Collisions": "Törmäykset", + "Colour": "Väri", + "Command": "Komento", + "Comment": "Kommentti", + "Component 1": "Komponentti 1", + "Component 2": "Komponentti 2", + "Component1": "Komponentti1", + "Components": "Komponentit", + "Config Table": "Konfiguraatiotaulukko", + "Configuration": "Konfiguraatio", + "Configuration rules": "Konfiguraatiosäännöt", + "Constant radius": "Vakiosäde", + "Contact": "Kosketus", + "Contact Sets": "Kosketusjoukot", + "Contact sets": "Kosketusjoukot", + "Continue on GitHub": "Jatka GitHubissa", + "Continuity": "Jatkuvuus", + "Control Radius": "Ohjaussäde", + "Copies the definition only. Assign it from Physical Material when ready.": "Kopioi vain määrityksen. Määritä se Fyysinen materiaali -kohdasta, kun olet valmis.", + "Copy Error Message": "Kopioi virheilmoitus", + "Copy Warning Message": "Kopioi varoitusilmoitus", + "Copy all licences": "Kopioi kaikki lisenssit", + "Copy diagnostic information": "Kopioi diagnostiikkatiedot", + "Copy report": "Kopioi raportti", + "Copy screenshot": "Kopioi kuvakaappaus", + "Corner Relief": "Kulmavapautus", + "Corner Relief Shape": "Kulmavapautuksen muoto", + "Corner radius": "Kulmasäde", + "Counterbore depth": "Upotuksen syvyys", + "Counterbore Ø": "Upotuksen Ø", + "Countersink angle": "Kartioupotuksen kulma", + "Countersink Ø": "Kartioupotuksen Ø", + "Create Hole": "Luo reikä", + "Create Issue — review draft": "Luo ilmoitus — tarkista luonnos", + "Create Issue…": "Luo ilmoitus…", + "Cross curves": "Poikkikäyrät", + "Ctrl+Enter continues on GitHub.": "Ctrl+Enter jatkaa GitHubissa.", + "Cube": "Kuutio", + "Current orthographic view (1:1 mm)": "Nykyinen ortografinen näkymä (1:1 mm)", + "Current perspective view": "Nykyinen perspektiivinäkymä", + "Current plane": "Nykyinen taso", + "Curv. Comb": "Kaar.kampa", + "Curvature (G2)": "Kaarevuus (G2)", + "Curvature change (G3)": "Kaarevuuden muutos (G3)", + "Custom": "Mukautettu", + "Customize Toolbar": "Mukauta työkaluriviä", + "Customize…": "Mukauta…", + "Cylindrical": "Sylinterimäinen", + "Cylindrical face": "Sylinteripinta", + "Deboss": "Painauma", + "Delete": "Poista", + "Delete Folders and Features…": "Poista kansiot ja piirteet…", + "Delete Selected Features…": "Poista valitut piirteet…", + "Delete Selected Folders": "Poista valitut kansiot", + "Delete all": "Poista kaikki", + "Delete rule": "Poista sääntö", + "Delete the row": "Poista rivi", + "Delete the set": "Poista joukko", + "Density": "Tiheys", + "Dependents": "Riippuvat kohteet", + "Describe the problem you saw": "Kuvaile havaitsemasi ongelma", + "Describe what happened before continuing.": "Kuvaile tapahtunut ennen kuin jatkat.", + "Designs you open or save appear here.": "Avaamasi tai tallentamasi mallit näkyvät tässä.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Määräytyy Kohteeseen-asetuksesta. Valitse Kulma, jos haluat muokata kulmalaajuutta.", + "Diameter": "Halkaisija", + "Did this assembly open the wrong way up?": "Avautuiko tämä kokoonpano väärin päin?", + "Did this part open the wrong way up?": "Avautuiko tämä osa väärin päin?", + "Dimension new shapes": "Mitoita uudet muodot", + "Direction": "Suunta", + "Discard": "Hylkää", + "Distance": "Etäisyys", + "Distance 1": "Etäisyys 1", + "Do not export hidden edges": "Älä vie piilosärmiä", + "Don't ask again": "Älä kysy uudelleen", + "Double-click to rename": "Nimeä uudelleen kaksoisnapsauttamalla", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Keskipainikkeen kaksoisnapsautus näkymässä: Koti (kotinäkymä, rajaa näytettävät kohteet), Sovita (rajaa näytettävät kohteet suunta säilyttäen) tai ei mitään", + "Drag to move the boss across the model. It snaps to sketch points on the face.": "Siirrä tappia mallin päällä vetämällä. Se tarttuu pinnan luonnospisteisiin.", + "Drag to move the hole; click to remove it": "Siirrä reikää vetämällä, poista se napsauttamalla", + "Drag to move the rest across the model. It snaps to sketch points on the face.": "Siirrä tukea mallin päällä vetämällä. Se tarttuu pinnan luonnospisteisiin.", + "Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Siirrä napsautusliitosta mallin päällä vetämällä. Se tarttuu pinnan luonnospisteisiin.", + "Drag to point the catch in another direction.": "Käännä salpa toiseen suuntaan vetämällä.", + "Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Käännä tukea 5° askelin vetämällä. Pidä Alt tai Ctrl painettuna, niin voit kääntää sitä vapaasti.", + "Drive Joint": "Ohjaa niveltä", + "Duration": "Kesto", + "Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Jokainen uusi muoto mitoitetaan myös luonnoksen origosta, kunnes mikään sen osa ei jää vapaaksi. Muoto, joka on jo kiinnitetty origoon tai sijoitettuun geometriaan, ei saa mittoja.", + "Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Jokainen piirtämäsi muoto saa kaikki mittansa piirtämäsi kokoisina (kirjoitetut arvot ovat etusijalla). Kokoa, jonka luonnos jo määrää, ei lisätä.", + "Edge": "Särmä", + "Edges": "Särmät", + "Edit": "Muokkaa", + "Edit Profile Sketch": "Muokkaa profiililuonnosta", + "Edit shortcuts": "Muokkaa pikanäppäimiä", + "Enable rule": "Ota sääntö käyttöön", + "Enabled": "Käytössä", + "End Radius": "Loppusäde", + "End bulge": "Pään pullistuma", + "End continuity": "Pään jatkuvuus", + "Equal Distance": "Tasaetäisyys", + "Expand": "Laajenna", + "Expected result (optional)": "Odotettu tulos (valinnainen)", + "Explode": "Räjäytä", + "Explode Text": "Pura teksti", + "Export Animation…": "Vie animaatio…", + "Export Bend Table…": "Vie taivutustaulukko…", + "Export CSV…": "Vie CSV…", + "Export Current View as DXF": "Vie nykyinen näkymä DXF-muodossa", + "Export Current View as DXF…": "Vie nykyinen näkymä DXF-muodossa…", + "Export DXF": "Vie DXF", + "Export DXF…": "Vie DXF…", + "Export JSON…": "Vie JSON…", + "Export design library...": "Vie mallin kirjasto...", + "Export hidden edges as dashed lines": "Vie piilosärmät katkoviivoina", + "Exported by SolidWorks, which usually saves Y up": "Viety SolidWorksista, joka tallentaa yleensä Y ylöspäin", + "Expression": "Lauseke", + "Extend to meet": "Jatka kohtaamiseen", + "Extents": "Ulottuvuus", + "FILES": "TIEDOSTOT", + "FLAT": "LEVITYS", + "Face": "Pinta", + "Face Appearance": "Pinnan ulkoasu", + "Face attachment lost": "Pintakiinnitys menetetty", + "Face center": "Pinnan keskipiste", + "Faces": "Pinnat", + "Faces / edges / vertices": "Pinnat / särmät / kärkipisteet", + "Faces to split": "Jaettavat pinnat", + "Faces/Body": "Pinnat/kappale", + "Factor must be a positive number": "Kertoimen on oltava positiivinen luku", + "Fastener": "Kiinnike", + "Favorites": "Suosikit", + "Favourite": "Suosikki", + "Feature": "Piirre", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Monien CAD-ohjelmien tiedostoissa Y on ylöspäin, joten ne avautuvat täällä selällään (Z ylöspäin).", + "Filter": "Suodatin", + "Filter…": "Suodata…", + "Find in Browser": "Etsi selaimesta", + "Finish": "Valmis", + "First edge": "Ensimmäinen särmä", + "First plane": "Ensimmäinen taso", + "First point": "Ensimmäinen piste", + "Fit and G2 corners require Constant radius.": "Sovitus- ja G2-kulmat edellyttävät vakiosädettä.", + "Fixed Face": "Kiinteä pinta", + "Flange Gap": "Laipan rako", + "Flange thickness": "Laipan paksuus", + "Flanges": "Laipat", + "Flat Pattern": "Levityskuva", + "Flat size": "Levityskoko", + "Flat size X": "Levityskoko X", + "Flat size Y": "Levityskoko Y", + "Follow camera (Look At sketches)": "Seuraa kameraa (Katso kohti -luonnokset)", + "Follow the camera (Look At sketches)": "Seuraa kameraa (Katso kohti -luonnokset)", + "Font": "Fontti", + "Free Move": "Vapaa siirto", + "From": "Mistä", + "From Bodies": "Kappaleista", + "From Snap": "Tartunnasta", + "From edges": "Särmistä", + "Full Length": "Koko pituus", + "Full thread depth": "Täysi kierteen syvyys", + "Further Bends": "Lisätaivutukset", + "G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2-kulmat edellyttävät vakiosädettä ja tarkkaa sovitusta. G2-kulmien muodostusta ei vielä tueta.", + "Gap": "Rako", + "Grid": "Ruudukko", + "HISTORY": "HISTORIA", + "Halo under sketch curves": "Korostusreunus luonnoskäyrien alla", + "Height": "Korkeus", + "Height From": "Korkeus alkaen", + "Hem Gap": "Huuloksen rako", + "Hidden edges": "Piilosärmät", + "History layout": "Historian asettelu", + "History names": "Historian nimet", + "Hole (optional)": "Reikä (valinnainen)", + "Hole Depth": "Reiän syvyys", + "Hole Diameter": "Reiän halkaisija", + "Holes": "Reiät", + "Holes (edges on them)": "Reiät (niiden särmät)", + "Home view": "Aloitusnäkymä", + "Horiz./Vert.": "Vaaka/pysty", + "I-Beam": "I-palkki", + "IMAGES": "KUVAT", + "If you are offline or GitHub does not open, use Copy report and keep the text to submit later.": "Jos olet offline-tilassa tai GitHub ei avaudu, käytä Kopioi raportti -toimintoa ja säilytä teksti myöhempää lähettämistä varten.", + "Import material into design": "Tuo materiaali malliin", + "Import…": "Tuo…", + "In This Design": "Tässä mallissa", + "In place (surfaces)": "Paikallaan (pinnat)", + "In plane": "Tasossa", + "Included": "Mukana", + "Inner Sharp": "Terävä sisäkulma", + "Inside": "Sisäpuoli", + "Instant": "Välitön", + "Interface font": "Käyttöliittymän fontti", + "Interferes": "Törmää", + "Invert": "Käänteinen", + "Isocurve": "Isokäyrä", + "Issue title": "Ilmoituksen otsikko", + "Items": "Kohteet", + "JOINTS": "NIVELET", + "Joint 1": "Nivel 1", + "Joint 2": "Nivel 2", + "Joint Origin1": "Nivelorigo1", + "K Factor": "K-kerroin", + "Keep (points on it)": "Säilytä (sen pisteet)", + "Keep Tools": "Säilytä työkalut", + "Keep of the other surface": "Säilytettävä osa toisesta pinnasta", + "Key a joint's value at this step": "Tallenna nivelen arvo avaimeksi tässä vaiheessa", + "Keys": "Avaimet", + "Knife Edge": "Veitsenterä", + "L-Angle": "L-profiili", + "LIBRARY": "KIRJASTO", + "LINKS": "LINKIT", + "Later": "Myöhemmin", + "Leave it": "Jätä ennalleen", + "Left Middle": "Vasemmalla keskellä", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Vanhanmallinen pintakiinnitys. Uudelleensidonta varmennettuun pintaan luo tarkan kiinnityksen.", + "Length": "Pituus", + "Length units": "Pituusyksiköt", + "Letter Spacing": "Merkkiväli", + "Library": "Kirjasto", + "Library file": "Kirjastotiedosto", + "Library response is missing": "Kirjaston vastaus puuttuu", + "Limits": "Raja-arvot", + "Line Spacing": "Riviväli", + "Line Type": "Viivatyyppi", + "Line on plane": "Viiva tasolla", + "Lines": "Viivat", + "Listing the installed fonts…": "Haetaan asennettuja fontteja…", + "Load library": "Lataa kirjasto", + "Local library": "Paikallinen kirjasto", + "Loop": "Silmukka", + "M5 or M5x0.5": "M5 tai M5x0.5", + "M5, M5x0.5, 1/4-20 UNC": "M5, M5x0.5, 1/4-20 UNC", + "MANAGE": "HALLITSE", + "MATERIALS": "MATERIAALIT", + "MODEL": "MALLI", + "MOTION": "LIIKE", + "Margin": "Marginaali", + "Mass": "Massa", + "Mass Center": "Massakeskipiste", + "Matched restrictions check the final values.": "Täsmäävät rajaukset tarkistavat lopulliset arvot.", + "Material": "Materiaali", + "Material Outside": "Materiaali ulkopuolella", + "Material copied. Existing body assignments are unchanged.": "Materiaali kopioitu. Kappaleiden nykyiset määritykset eivät muuttuneet.", + "Max": "Maks.", + "Max Thickness": "Suurin paksuus", + "Maximum": "Enimmäisarvo", + "Mesh body": "Verkkokappale", + "Mid Radius": "Keskisäde", + "Middle double-click": "Keskipainikkeen kaksoisnapsautus", + "Min": "Min.", + "Min Draft": "Pienin päästö", + "Min Thickness": "Pienin paksuus", + "Minimum": "Vähimmäisarvo", + "Mirror Plane": "Peilaustaso", + "Mitre": "Jiiri", + "Model Parameters": "Mallin parametrit", + "Modeled": "Mallinnettu", + "More toolbar panels": "Lisää työkalurivin paneeleja", + "Motion": "Liike", + "Mouse gestures": "Hiirieleet", + "Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "Hiirieleet toimivat vain SolidWorks-hiiritilassa (rulla kiertää, Ctrl + rulla panoroi).", + "Move / Copy": "Siirrä / kopioi", + "Move Type": "Siirron tyyppi", + "Move down": "Siirrä alas", + "Move earlier": "Siirrä aiemmaksi", + "Move holes by clicking or dragging": "Siirrä reikiä napsauttamalla tai vetämällä", + "Move later": "Siirrä myöhemmäksi", + "Move up": "Siirrä ylös", + "Name": "Nimi", + "Name on import": "Nimi tuotaessa", + "Nearest": "Lähin", + "Neutral plane": "Neutraalitaso", + "New": "Uusi", + "New Comp.": "Uusi komp.", + "New Sketch": "Uusi luonnos", + "New rule…": "Uusi sääntö…", + "New study": "Uusi tutkimus", + "No crash reports.": "Ei kaatumisraportteja.", + "No matching appearance presets.": "Ei vastaavia ulkoasun esiasetuksia.", + "No matching appearances": "Ei vastaavia ulkoasuja", + "No matching physical materials.": "Ei vastaavia fyysisiä materiaaleja.", + "No visible bodies or sketches to export.": "Ei näkyviä kappaleita tai luonnoksia vietäväksi.", + "Nominal Radius": "Nimellissäde", + "Nominal diameter": "Nimellishalkaisija", + "None": "Ei mitään", + "Not available yet": "Ei vielä käytettävissä", + "Not saved yet": "Ei vielä tallennettu", + "Notch": "Lovi", + "OUTPUT": "TULOSTE", + "Of": "/", + "On your computer": "Tietokoneellasi", + "Opacity": "Peittävyys", + "Open GitHub": "Avaa GitHub", + "Open GitHub draft": "Avaa GitHub-luonnos", + "Open GitHub issue form": "Avaa GitHubin ilmoituslomake", + "Open bodies (no volume)": "Avoimet kappaleet (ei tilavuutta)", + "Open library...": "Avaa kirjasto...", + "Open local libraries in the desktop app. You can export this design's library here.": "Avaa paikalliset kirjastot työpöytäsovelluksessa. Tämän mallin kirjaston voit viedä täällä.", + "Open surface — no volume": "Avoin pinta — ei tilavuutta", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Avoimen lähdekoodin parametrinen 3D-CAD, kirjoitettu Rustilla. Lisenssi: MIT OR Apache-2.0.", + "Opens your browser and copies the report text for you to paste into GitHub.": "Avaa selaimen ja kopioi raportin tekstin, jotta voit liittää sen GitHubiin.", + "Opens your browser and copies the report text. Review and submit your report on GitHub.": "Avaa selaimen ja kopioi raportin tekstin. Tarkista ja lähetä raportti GitHubissa.", + "Opens your browser. Review and submit your report on GitHub.": "Avaa selaimen. Tarkista ja lähetä raportti GitHubissa.", + "Operator": "Operaattori", + "Orbit (right drag)": "Kierrä (veto oikealla painikkeella)", + "Other side": "Toinen puoli", + "Other surface (mutual trim)": "Toinen pinta (keskinäinen katkaisu)", + "Other way": "Toiseen suuntaan", + "Outer Sharp": "Terävä ulkokulma", + "Outer radius": "Ulkosäde", + "Outside": "Ulkopuoli", + "Overall length": "Kokonaispituus", + "Param. Graph": "Param.kuvaaja", + "Parameter": "Parametri", + "Part length must be greater than zero and at most 2000 mm": "Osan pituuden on oltava suurempi kuin nolla ja enintään 2000 mm", + "Parts": "Osat", + "Pass through": "Läpi", + "Path": "Polku", + "Pattern": "Kuvio", + "Per Edge": "Särmäkohtainen", + "Physical material definitions": "Fyysisten materiaalien määritykset", + "Pick a part size": "Valitse osan koko", + "Pick the tool bodies": "Valitse työkalukappaleet", + "Picked": "Valittu", + "Picked point": "Valittu piste", + "Pin-slot": "Tappi-ura", + "Pitch": "Nousu", + "Pivot": "Kääntöpiste", + "Place at the origin instead": "Sijoita sen sijaan origoon", + "Planar": "Tasomainen", + "Plastic Rules": "Muovisäännöt", + "Play the joint's motion in the view": "Toista nivelen liike näkymässä", + "Playback": "Toisto", + "Plot": "Kuvaaja", + "Point on line": "Piste viivalla", + "Point on plane": "Piste tasolla", + "Position": "Sijainti", + "Position (0 to 1)": "Sijainti (0–1)", + "Position (G0)": "Sijainti (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Sijainti särmää pitkin: 0 alkupäässä, 1 toisessa päässä", + "Preferences › Design turns the offer back on": "Asetukset › Suunnittelu ottaa ehdotuksen taas käyttöön", + "Preview": "Esikatselu", + "Preview Row": "Esikatsele riviä", + "Preview leaves the current design unchanged.": "Esikatselu ei muuta nykyistä mallia.", + "Profile / Edges": "Profiili / särmät", + "Profiles or curves": "Profiilit tai käyrät", + "Profiles/Curves": "Profiilit/käyrät", + "Quantity": "Määrä", + "Quantity must be at least 2": "Määrän on oltava vähintään 2", + "RECENT": "VIIMEISIMMÄT", + "REFERENCE": "VIITE", + "RULED SURFACE": "VIIVOITINPINTA", + "RULES": "SÄÄNNÖT", + "Radius": "Säde", + "Radius Fit": "Säteen sovitus", + "Ratio": "Suhde", + "Read-only list: shortcuts can't be changed here.": "Vain luku -luettelo: pikanäppäimiä ei voi muuttaa täällä.", + "Reattach selected face": "Kiinnitä valittu pinta uudelleen", + "Recover": "Palauta", + "Recover unsaved design?": "Palautetaanko tallentamaton malli?", + "Rectangular": "Suorakulmainen", + "Rectangular Tube": "Suorakaideputki", + "Reference face": "Referenssipinta", + "Relief Depth": "Vapautuksen syvyys", + "Relief Width": "Vapautuksen leveys", + "Remove Tools": "Poista työkalut", + "Remove condition": "Poista ehto", + "Remove from Folder": "Poista kansiosta", + "Remove from list": "Poista luettelosta", + "Remove override": "Poista ohitus", + "Remove private design details or paths before sharing.": "Poista mallin yksityiset tiedot ja polut ennen jakamista.", + "Remove screenshot": "Poista kuvakaappaus", + "Remove the column": "Poista sarake", + "Remove the track": "Poista raita", + "Remove this bend": "Poista tämä taivutus", + "Remove this point": "Poista tämä piste", + "Rename Feature": "Nimeä piirre uudelleen", + "Rename Folder": "Nimeä kansio uudelleen", + "Render Stage": "Renderöintinäyttämö", + "Reopen designs": "Avaa mallit uudelleen", + "Reopen the designs that were open when SolidCraft was last closed": "Avaa uudelleen mallit, jotka olivat auki, kun SolidCraft viimeksi suljettiin", + "Replace existing library file": "Korvaa olemassa oleva kirjastotiedosto", + "Report a bug — review a screenshot and open a GitHub draft": "Ilmoita virheestä — tarkista kuvakaappaus ja avaa GitHub-luonnos", + "Report copied. Keep this text to submit on GitHub when you are online.": "Raportti kopioitu. Säilytä tämä teksti ja lähetä se GitHubissa, kun olet verkossa.", + "Report text copied. Paste it into GitHub and submit your report there.": "Raportin teksti kopioitu. Liitä se GitHubiin ja lähetä raportti siellä.", + "Reports are public. A GitHub account is required.": "Raportit ovat julkisia. GitHub-tili vaaditaan.", + "Reset Toolbar": "Palauta työkalurivin oletukset", + "Resize Part": "Muuta osan kokoa", + "Restore View": "Palauta näkymä", + "Restore outer boundary": "Palauta ulkoreuna", + "Restore the inherited component or physical material appearance": "Palauta komponentilta tai fyysiseltä materiaalilta peritty ulkoasu", + "Restore this studio's tools": "Palauta tämän studion työkalut", + "Result": "Tulos", + "Retake screenshot": "Ota kuvakaappaus uudelleen", + "Reveal Height": "Esiin jäävä korkeus", + "Reverse": "Käännä", + "Reverse Direction": "Käännä suunta", + "Revert": "Palauta", + "Revert All Positions": "Palauta kaikki asennot", + "Revert Pos.": "Palauta as.", + "Revert Position": "Palauta sijainti", + "Review the report and screenshot before opening GitHub.": "Tarkista raportti ja kuvakaappaus ennen GitHubin avaamista.", + "Review your report on GitHub and click Submit new issue to publish it.": "Tarkista raporttisi GitHubissa ja julkaise se napsauttamalla Submit new issue.", + "Revolute": "Kiertyvä", + "Rib Length": "Rivan pituus", + "Rib Offset": "Rivan siirtymä", + "Rib Thickness": "Rivan paksuus", + "Ribs": "Rivat", + "Right Middle": "Oikealla keskellä", + "Rigid": "Jäykkä", + "Rim Edge": "Reunasärmä", + "Rim face": "Reunapinta", + "Ring of tools on the right button": "Työkalurengas hiiren oikeassa painikkeessa", + "Roll Back Here": "Palauta tähän", + "Roll History Marker Here": "Siirrä historiamerkki tähän", + "Root radius": "Pohjan säde", + "Round": "Pyöreä", + "Round Bar": "Pyörötanko", + "Round Tube": "Pyöröputki", + "Rule": "Sääntö", + "Rule name": "Säännön nimi", + "Rules run from top to bottom. Matched drives change effective values.": "Säännöt suoritetaan ylhäältä alas. Täsmäävät ohjaukset muuttavat voimassa olevia arvoja.", + "SECTION": "LEIKKAUS", + "SURFACE QUALITY": "PINNAN LAATU", + "Same way": "Samaan suuntaan", + "Save Rule": "Tallenna sääntö", + "Save View": "Tallenna näkymä", + "Save screenshot…": "Tallenna kuvakaappaus…", + "Save the study as an animated GIF, seen as the viewport shows it": "Tallenna tutkimus animoituna GIF-kuvana sellaisena kuin näkymäikkuna sen näyttää", + "Saved preview. Geometry is being checked before editing.": "Tallennettu esikatselu. Geometriaa tarkistetaan ennen muokkausta.", + "Scale factor": "Skaalauskerroin", + "Scene": "Kohtaus", + "Screenshot (optional)": "Kuvakaappaus (valinnainen)", + "Search": "Hae", + "Search commands": "Hae komentoja", + "Search this library": "Hae tästä kirjastosta", + "Second edge": "Toinen särmä", + "Second plane": "Toinen taso", + "Second point": "Toinen piste", + "Section": "Leikkaus", + "Section (optional)": "Leikkaus (valinnainen)", + "Section position": "Leikkauksen sijainti", + "Sections (one sketch each)": "Poikkileikkaukset (yksi luonnos kukin)", + "Select Other": "Valitse muu", + "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).": "Valitse muu: pidä vasenta painiketta paikallaan mallin päällä (tai napsauta hiiren oikealla › Valitse muu…), niin kaikki kohdistimen alla olevat kohteet luetellaan; napsauta uudelleen samaa kohtaa, niin seuraava kohde valitaan. Kaksoisnapsauta keskipainiketta siirtyäksesi kotinäkymään (Asetukset › Navigointi).", + "Select Same Look": "Valitse sama ulkoasu", + "Select bodies (a face picks its body)": "Valitse kappaleet (pinta valitsee kappaleensa)", + "Select bodies or faces instead": "Valitse sen sijaan kappaleet tai pinnat", + "Select edges": "Valitse särmät", + "Select points of the sketch being edited": "Valitse muokattavan luonnoksen pisteet", + "Select the centre the pattern turns around first": "Valitse ensin keskipiste, jonka ympäri kuvio kiertää", + "Select the curves to offset first": "Valitse ensin siirrettävät käyrät", + "Select the mirror line": "Valitse peilausviiva", + "Select the sketch curves or points to mirror first": "Valitse ensin peilattavat luonnoskäyrät tai -pisteet", + "Select the sketch curves or points to move first": "Valitse ensin siirrettävät luonnoskäyrät tai -pisteet", + "Select the sketch curves or points to pattern first": "Valitse ensin kuvioitavat luonnoskäyrät tai -pisteet", + "Select the sketch curves or points to scale first": "Valitse ensin skaalattavat luonnoskäyrät tai -pisteet", + "Select the sketch curves or points to transform first": "Valitse ensin muunnettavat luonnoskäyrät tai -pisteet", + "Selection filter": "Valintasuodatin", + "Semantic faces share their appearance across instances.": "Semanttiset pinnat jakavat ulkoasunsa kaikkien esiintymien kesken.", + "Send your report on GitHub": "Lähetä raporttisi GitHubissa", + "Shaded": "Varjostettu", + "Shape": "Muoto", + "Sheet Rules": "Levysäännöt", + "Show Agent Cursor": "Näytä agentin kohdistin", + "Show ghost": "Näytä haamukuva", + "Show in folder": "Näytä kansiossa", + "Show the pointer an AI agent moves (#35)": "Näytä tekoälyagentin liikuttama osoitin (#35)", + "Show tool at cursor": "Näytä työkalu kohdistimen kohdalla", + "Signed axes": "Etumerkilliset akselit", + "Size": "Koko", + "Sketch constraints": "Luonnoksen rajoitteet", + "Sketch curves": "Luonnoskäyrät", + "Sketch dimensions": "Luonnoksen mitat", + "Sketch geometry and profiles": "Luonnoksen geometria ja profiilit", + "Sketch plane has no body-face attachment.": "Luonnostasolla ei ole kiinnitystä kappaleen pintaan.", + "Sketch points": "Luonnospisteet", + "Slider": "Liukuva", + "Slot width": "Uran leveys", + "Snap to face and edge centres": "Tartu pintojen ja särmien keskipisteisiin", + "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft ei voi lähettää raportteja suoraan. Lähetä GitHubissa; sovelluksessa ei tarvita tunnusta.", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft suljettiin ennen kuin nämä mallit tallennettiin. Niiden viimeisin automaattinen tallennus voidaan avata.", + "Spacing 2 must be a non-zero number": "Välin 2 on oltava nollasta poikkeava luku", + "Spacing must be a non-zero number": "Välin on oltava nollasta poikkeava luku", + "Specialise": "Erikoista", + "Speed": "Nopeus", + "Splitting tool": "Jakotyökalu", + "Square": "Neliö", + "Stand it up": "Nosta pystyyn", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Nosta pystyyn: neljänneskierros X-akselin ympäri yhtenä Siirrä-piirteenä, jonka voit kumota.", + "Stand this assembly up?": "Nostetaanko tämä kokoonpano pystyyn?", + "Stand this part up?": "Nostetaanko tämä osa pystyyn?", + "Start angle": "Alkukulma", + "Start bulge": "Alun pullistuma", + "Start continuity": "Alun jatkuvuus", + "Stationary Side": "Kiinteä puoli", + "Step": "Vaihe", + "Steps": "Vaiheet", + "Steps to reproduce (optional)": "Toistamisen vaiheet (valinnainen)", + "Stitch the kept pieces": "Ompele säilytetyt palat", + "Stitch to both bodies": "Ompele molempiin kappaleisiin", + "Studio": "Studio", + "Study": "Tutkimus", + "Style": "Tyyli", + "Summary": "Yhteenveto", + "Suppress": "Ohita", + "Suppress Selected Folders": "Ohita valitut kansiot", + "Suppressed": "Ohitettu", + "Surface": "Pinta", + "Surface, or faces to turn over": "Pinta tai käännettävät pinnat", + "Surfaces": "Pinnat", + "Swap Ends": "Vaihda päät", + "TRIM": "KATKAISE", + "Tangent": "Tangentti", + "Tangent (G1)": "Tangentti (G1)", + "Tangent Chain": "Tangenttiketju", + "Tangent chain": "Tangenttiketju", + "Tangent to Edge": "Tangentti särmään", + "Tap drill diameter": "Kierreporan halkaisija", + "Target": "Kohde", + "Target Body": "Kohdekappale", + "Target Face (optional)": "Kohdepinta (valinnainen)", + "Tear": "Repäisy", + "Technical details": "Tekniset tiedot", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Katkoviivainen luonnos on tallennettu tasoapu. Korjaa kiinnitys valitsemalla uusi pinta.", + "The design changed. Reopen Change Part Size for the component.": "Malli muuttui. Avaa Muuta osan kokoa uudelleen komponentille.", + "The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Suunnan on oltava akseli tai suora luonnoskäyrä, joka on luonnostasossa", + "The mirror line cannot also be mirrored": "Peilausviivaa ei voi myös peilata", + "The mirror line must be a straight curve of this sketch, not an origin axis": "Peilausviivan on oltava tämän luonnoksen suora käyrä, ei origon akseli", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Tallennettu laatijan esiintymä puuttuu. Valitse kelvollinen laatijan sijoitus ennen uudelleensidontaa.", + "Then": "Sitten", + "These features will be deleted:": "Nämä piirteet poistetaan:", + "These features will fail:": "Nämä piirteet epäonnistuvat:", + "Thickness Outside": "Paksuus ulospäin", + "Thickness Variation": "Paksuuden vaihtelu", + "Thin": "Ohut", + "Thin Extrude": "Ohut pursotus", + "Third plane": "Kolmas taso", + "Third point": "Kolmas piste", + "This child is shared. Deleting it removes it from every copy of its parent.": "Tämä alikohde on jaettu. Sen poistaminen poistaa sen jokaisesta yläkohteen kopiosta.", + "This child is shared. Position changes apply to every copy of its parent.": "Tämä alikohde on jaettu. Sijaintimuutokset koskevat jokaista yläkohteen kopiota.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Tämä komponentti on alikokoonpanossa, joka on sijoitettu useammin kuin kerran: kohdistaminen siirtää sitä jokaisessa sijoituksessa.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Tämä pinta ei anna pysyvää kiinnitystä. Valitse toinen pinta tai käytä nimenomaisesti kiinteää tasoa.", + "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Tätä kuvaa ei ole vielä liitetty GitHubiin. Kopioi tai tallenna se ja liitä se sitten GitHubissa.", + "Thread depth": "Kierteen syvyys", + "Thread diameter": "Kierteen halkaisija", + "Through point": "Pisteen kautta", + "To": "Kohteeseen", + "To Offset": "Siirtymään", + "To Snap": "Tartuntaan", + "To edges": "Särmiin", + "Toe radius": "Kärjen säde", + "Tolerance": "Toleranssi", + "Too close": "Liian lähellä", + "Tool Bodies": "Työkalukappaleet", + "Tools": "Työkalut", + "Tools, clockwise from the top": "Työkalut myötäpäivään ylhäältä alkaen", + "Top Left": "Ylhäällä vasemmalla", + "Top Middle": "Ylhäällä keskellä", + "Top Right": "Ylhäällä oikealla", + "Trimming tool (plane, face or curves)": "Katkaisutyökalu (taso, pinta tai käyrät)", + "Turn off section analysis before exporting the view.": "Poista leikkausanalyysi käytöstä ennen näkymän vientiä.", + "Type": "Tyyppi", + "Type a distance or an angle to move by": "Kirjoita siirron etäisyys tai kulma", + "UNTRIM": "PERU KATKAISU", + "Unit": "Yksikkö", + "Unsuppress": "Palauta käyttöön", + "Unsuppress Selected Folders": "Palauta valitut kansiot käyttöön", + "Updating the preview": "Päivitetään esikatselua", + "Use active author placement": "Käytä aktiivista laatijan sijoitusta", + "Use fixed plane": "Käytä kiinteää tasoa", + "Use saved fixed plane": "Käytä tallennettua kiinteää tasoa", + "Use selected face as fixed plane": "Käytä valittua pintaa kiinteänä tasona", + "Use the SolidWorks mouse mode": "Käytä SolidWorksin hiiritilaa", + "User Parameters": "Käyttäjän parametrit", + "VIEW": "NÄKYMÄ", + "VIEWS": "NÄKYMÄT", + "Value": "Arvo", + "Variable Radius": "Muuttuva säde", + "Velocity": "Nopeus", + "Vertices": "Kärkipisteet", + "View navigator": "Näkymänavigaattori", + "Volume": "Tilavuus", + "Wall": "Seinämä", + "Wall Location": "Seinämän sijainti", + "Wall Thickness": "Seinämän paksuus", + "Web thickness": "Uuman paksuus", + "What did you do just before the problem? A short list is enough": "Mitä teit juuri ennen ongelmaa? Lyhyt luettelo riittää", + "What did you expect to see?": "Mitä odotit näkeväsi?", + "What happened?": "Mitä tapahtui?", + "When": "Kun", + "Width": "Leveys", + "Will fail": "Epäonnistuu", + "With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "Kun eleet ovat käytössä, oikea painike ei enää kierrä näkymää: pidä sitä painettuna ja liikuta hiirtä avataksesi renkaan, ja kosketa sitten työkalun aluetta. Pelkkä oikea napsautus avaa edelleen valikon.", + "X": "X", + "X Angle": "X-kulma", + "X angle": "X-kulma", + "X distance": "X-etäisyys", + "Y": "Y", + "Y Angle": "Y-kulma", + "Y angle": "Y-kulma", + "Y distance": "Y-etäisyys", + "Z": "Z", + "Z Angle": "Z-kulma", + "Z Distance": "Z-etäisyys", + "Z angle": "Z-kulma", + "Z distance": "Z-etäisyys", + "Zebra": "Seepra", + "`joint` must be an id or a name": "`joint` on oltava tunniste tai nimi", + "`params` must be an object": "`params` on oltava olio", + "`values` must be a list": "`values` on oltava lista", + "a chamfer needs a corner of exactly two lines": "viiste tarvitsee kulman, jossa on täsmälleen kaksi viivaa", + "a file operation is in progress": "tiedostotoiminto on käynnissä", + "a picked curve is no longer in a sketch": "valittu käyrä ei ole enää luonnoksessa", + "a picked curve's sketch has not been solved": "valitun käyrän luonnosta ei ole ratkaistu", + "a picked edge has no points": "valitulla särmällä ei ole pisteitä", + "a picked edge is gone": "valittu särmä on poistunut", + "a picked edge's body is gone": "valitun särmän kappale on poistunut", + "add a key for a joint first": "lisää ensin avain nivelelle", + "ai.connected_toast": "SolidCraft on nyt käytettävissä sovelluksissa {clients}. Käynnistä ne uudelleen ottaaksesi sen käyttöön (Asetukset > Avustaja).", + "ai.prefs.allow_control": "Salli tekoälyagenttien yhdistää (ohjauskanava / MCP)", + "ai.prefs.allow_control_note": "Avaa ohjauskanavan vain tälle tietokoneelle (127.0.0.1, portti 27878 tai seuraava vapaa). Yhdistävän ohjelman on myös esitettävä salainen tunniste, jonka vain käyttäjätilisi voi lukea ja joka luodaan uutena jokaisella käynnistyksellä. Tulee voimaan, kun SolidCraft käynnistetään seuraavan kerran. Pois: mikään ei kuuntele, ellei SolidCraftia käynnistetä valitsimella --control.", + "ai.prefs.availability": "Avustajan hallinta", + "ai.prefs.background_commands": "Suorita agentin komennot taustalla", + "ai.prefs.connect_apps": "Tuo SolidCraft tämän tietokoneen tekoälysovellusten käyttöön", + "ai.prefs.connect_apps_note": "Kun SolidCraft käynnistyy, se lisätään MCP-palvelimeksi tältä tietokoneelta löytyviin tekoälysovelluksiin (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), jotta agentti voi käyttää sitä ilman asetuksia. Vain solidcraft-merkintä lisätään tai sen polku päivitetään; muuttamaasi merkintään ei kosketa, ja tiedostosta otetaan ensin varmuuskopio. Pois: lisätyt merkinnät poistetaan. Ohje > Yhdistä tekoälyavustajat… tekee tämän käsin.", + "ai.prefs.connect_at_start": "Yhdistä viimeksi käytetty agentti, kun SolidCraft käynnistyy", + "ai.prefs.connect_at_start_note": "Käynnistää viimeksi yhdistämäsi agentin (tai oletusagentin) taustalle aina, kun SolidCraft käynnistyy, jotta se on valmiina keskusteluun. Ei, kun SolidCraft käynnistetään valitsimella --control. Jos yhteys ei onnistu, Avustaja näyttää ilmoituksen Yhteys epäonnistui ja Yritä uudelleen -painikkeen.", + "ai.prefs.follow_camera": "Käännä kamera luonnoksiin", + "ai.prefs.follow_camera_off": "Kameran automaattinen seuraaminen on pois käytöstä.", + "ai.prefs.follow_camera_on": "Kamera kääntyy kohti agentin luomia luonnoksia.", + "ai.prefs.intro": "Agentit, käyttöoikeudet ja miten niiden työ näkyy.", + "ai.prefs.make_default": "Käytä oletuksena", + "ai.prefs.no_agents": "Ei vielä agentteja. Lisää agentti tai päivitä löytääksesi asennetut agentit.", + "ai.prefs.no_saved_approvals": "Ei tallennettuja hyväksyntöjä.", + "ai.prefs.open_at_start": "Avaa Avustaja-paneeli, kun SolidCraft käynnistyy", + "ai.prefs.open_at_start_note": "Näyttää Avustaja-paneelin (tai sen oman ikkunan) aina, kun SolidCraft käynnistyy. Ei, kun SolidCraft käynnistetään valitsimella --control.", + "ai.prefs.override_all_warning": "Tämä agentti voi tallentaa, korvata, viedä tai vaihtaa mallin kysymättä.", + "ai.prefs.permission_all": "Salli kaikki", + "ai.prefs.permission_all_summary": "Käytettävissä olevat SolidCraft-työkalut suoritetaan kysymättä.", + "ai.prefs.permission_ask": "Kysy ennen muutoksia", + "ai.prefs.permission_ask_summary": "Hyväksy jokainen mallimuutos ja tiedostotoiminto.", + "ai.prefs.permission_edits": "Salli mallin muokkaukset", + "ai.prefs.permission_edits_summary": "Mallin muokkaukset suoritetaan; tiedostotoiminnot ja tuhoavat toiminnot kysytään edelleen.", + "ai.prefs.permission_scope": "Koskee vain SolidCraft-työkaluja. Agentin komentotulkki ja tiedostomuokkaukset käyttävät sen omia käyttöoikeuksia.", + "ai.prefs.pointer_preview_hidden": "Osoitin piilotettu", + "ai.prefs.pointer_preview_speed": "Agentin osoitin · {speed}", + "ai.prefs.pointer_speed": "Liikenopeus", + "ai.prefs.privacy_note": "Vain valitsemasi agentti tai päätepiste saa keskustelun. Päätepisteiden avaimet tallennetaan järjestelmän avainnippuun.", + "ai.prefs.show_pointer": "Näytä agentin osoitin", + "ai.prefs.turn_limit_hint": "Mallia muuttavien työkalukutsujen enimmäismäärä, jonka Avustaja voi tehdä yhtä viestiä kohden.", + "angle, height": "kulma, korkeus", + "appearance.dielectric_ior": "Dielektrinen IOR", + "appearance.metallic": "Metallisuus", + "appearance.only_available": "Vain käytettävissä olevat ulkoasut", + "appearance.pbr_help": "Näytä tämä pinta renderöinnissä tai tuote-PNG:ssä. Ei taittumista eikä läpäisyä.", + "appearance.pbr_opaque": "Läpinäkymätön PBR", + "appearance.roughness": "Karheus", + "appearance.surface_response": "Pinnan vaste", + "at the face": "pinnassa", + "bom.group_children": "Alaosat osansa rivillä", + "browser.body_name": "Kappale{number}", + "browser.calibrate_click_a": "Napsauta kuvaa kohdassa Piste A tai kirjoita sen koordinaatit.", + "browser.calibrate_click_b": "Napsauta kuvaa kohdassa Piste B tai kirjoita sen koordinaatit.", + "browser.calibrate_title": "Kalibroi: {name}", + "browser.capture_many": "{count} komponenttia siirrettiin, mutta niiden sijainteja ei ole tallennettu.", + "browser.capture_note": "Tallenna sijainti säilyttää uuden sijainnin; Palauta siirtää sen takaisin.", + "browser.capture_one": "Komponenttia siirrettiin, mutta sen sijaintia ei ole tallennettu.", + "browser.component_name": "Komponentti{number}", + "browser.edit_canvas_title": "Muokkaa pohjakuvaa: {name}", + "browser.edit_point_cloud_title": "Muokkaa pistepilveä: {name}", + "browser.move_title": "Siirrä: {name}", + "browser.parameters": "Parametrit ({count})", + "browser.precision": "Tarkkuus: {precision}", + "browser.sketch_attachment_lost": "{name} (kiinnitys kadonnut)", + "browser.sketch_editing": "{name} (muokataan)", + "browser.sketch_name": "Luonnos{number}", + "browser.units": "Yksiköt: {units}", + "cam.flat": "Tasainen pinta", + "cam.follower": "Seuraaja", + "cam.knife": "Veitsisärmä", + "cam.needs_joints": "tarvitsee nivelen, joka pyörii, ja nivelen, joka liukuu tai heilahtaa", + "cam.roller": "Rulla", + "cam.roller_pick": "Rulla (valinnainen)", + "cam.tip_pick": "Kärki (valinnainen)", + "camera.shot_pitch": "Nyökkäys", + "camera.shot_roll": "Kallistus", + "camera.shot_yaw": "Kääntö", + "canvas.decal_place_hint": "Sijoita tarra napsauttamalla pintaa", + "canvas.place_hint": "Sijoita pohjakuva napsauttamalla tasoa tai pintaa", + "choose a profile": "valitse profiili", + "choose the cam's and the follower's joints": "valitse nokan ja seuraajan nivelet", + "choose the joints to couple": "valitse kytkettävät nivelet", + "click a plane or planar face": "napsauta tasoa tai tasopintaa", + "click sketch curves": "napsauta luonnoskäyriä", + "cmd.appearance_assign": "Ulkoasu", + "cmd.appearance_chain": "Ulkoasuketju", + "cmd.appearance_copy": "Kopioi ulkoasu", + "cmd.appearance_favorite": "Suosikkiulkoasu", + "cmd.appearance_library": "Ulkoasukirjasto", + "cmd.appearance_list": "Luettele ulkoasut", + "cmd.appearance_paint": "Maalaa ulkoasu", + "cmd.appearance_paste": "Liitä ulkoasu", + "cmd.appearance_pick_up": "Poimi ulkoasu", + "cmd.appearance_replace": "Korvaa ulkoasu", + "cmd.assembly_bom": "Osaluettelo", + "cmd.body.print_face": "Tulostuspinta", + "cmd.body_rename": "Nimeä kappale uudelleen", + "cmd.browser_collapse_all": "Kutista kaikki", + "cmd.browser_expand_all": "Laajenna kaikki", + "cmd.browser_group": "Uusi ryhmä", + "cmd.browser_move": "Siirrä selaimen kohteita", + "cmd.browser_order": "Järjestä selaimen kohteet", + "cmd.browser_rename_group": "Nimeä ryhmä uudelleen", + "cmd.browser_reparent": "Siirrä komponenttiin", + "cmd.browser_ungroup": "Pura ryhmä", + "cmd.browser_visibility": "Näytä/piilota", + "cmd.canvas_calibrate": "Kalibroi pohjakuva", + "cmd.canvas_decal": "Tarra", + "cmd.canvas_delete": "Poista pohjakuva", + "cmd.canvas_edit": "Muokkaa pohjakuvaa", + "cmd.canvas_insert": "Pohjakuva", + "cmd.canvas_to_sketch": "Pohjakuva luonnokseksi", + "cmd.component_activate": "Aktivoi komponentti", + "cmd.component_break_link": "Katkaise linkki", + "cmd.component_capture_position": "Tallenna sijainti", + "cmd.component_copies": "Etsi identtiset kopiot", + "cmd.component_copy_bodies": "Liitä kappaleet", + "cmd.component_create": "Uusi komponentti", + "cmd.component_cycle_color": "Vaihda komponentin väriä", + "cmd.component_delete": "Poista komponentti", + "cmd.component_edit_in_place": "Muokkaa paikallaan", + "cmd.component_extract": "Irrota tiedostoksi", + "cmd.component_finish_edit": "Lopeta muokkaus", + "cmd.component_from_bodies": "Luo komponentit kappaleista", + "cmd.component_insert_design": "Lisää malli", + "cmd.component_links": "Luettele linkit", + "cmd.component_list": "Luettele komponentit", + "cmd.component_metadata": "Osan ominaisuudet", + "cmd.component_metadata_set": "Muuta osan ominaisuuksia", + "cmd.component_move_bodies": "Siirrä kappaleet komponenttiin", + "cmd.component_move_features": "Siirrä piirteet komponenttiin", + "cmd.component_move_sketches": "Siirrä luonnokset komponenttiin", + "cmd.component_paste_from_design": "Liitä toisesta mallista", + "cmd.component_paste_new": "Liitä uutena", + "cmd.component_properties": "Komponentin fysikaaliset ominaisuudet", + "cmd.component_relink": "Linkitä lähde uudelleen", + "cmd.component_rename": "Nimeä komponentti uudelleen", + "cmd.component_replace_with_link": "Korvaa linkillä", + "cmd.component_revert_position": "Palauta sijainti", + "cmd.component_unlock": "Avaa sisällön lukitus", + "cmd.component_update_links": "Päivitä linkit", + "cmd.config_activate": "Aktivoi konfiguraatio", + "cmd.config_column": "Konfiguraatiosarake", + "cmd.config_configure": "Konfiguroi", + "cmd.config_preview": "Esikatsele konfiguraatiota", + "cmd.config_row": "Konfiguraatiorivi", + "cmd.config_rule": "Konfiguraatiosääntö", + "cmd.config_rules": "Konfiguraatiosäännöt", + "cmd.config_table": "Näytä konfiguraatiotaulukko", + "cmd.construct_axis_edge": "Akseli särmän kautta", + "cmd.construct_axis_normal_to_face": "Akseli kohtisuoraan pintaan", + "cmd.construct_axis_through_cylinder": "Akseli sylinterin/kartion/toruksen kautta", + "cmd.construct_axis_two_planes": "Akseli kahden tason kautta", + "cmd.construct_axis_two_points": "Akseli kahden pisteen kautta", + "cmd.construct_plane_along_path": "Taso polkua pitkin", + "cmd.construct_plane_midplane": "Keskitaso", + "cmd.construct_plane_perpendicular": "Kohtisuora taso", + "cmd.construct_plane_tangent": "Tangenttitaso", + "cmd.construct_plane_three_points": "Taso kolmen pisteen kautta", + "cmd.construct_plane_two_edges": "Taso kahden särmän kautta", + "cmd.construct_point_along_path": "Piste polkua pitkin", + "cmd.construct_point_center": "Piste ympyrän/pallon/toruksen keskipisteeseen", + "cmd.construct_point_edge_plane": "Piste särmän ja tason leikkaukseen", + "cmd.construct_point_three_planes": "Piste kolmen tason kautta", + "cmd.construct_point_two_edges": "Piste kahden särmän kautta", + "cmd.construct_point_vertex": "Piste kärkipisteeseen", + "cmd.contact_check": "Tarkista kosketus", + "cmd.contact_create": "Uusi kosketusjoukko", + "cmd.contact_disable_all": "Poista kosketus käytöstä", + "cmd.contact_edit": "Muokkaa kosketusjoukkoa", + "cmd.contact_enable_all": "Ota kaikki kosketus käyttöön", + "cmd.contact_enable_sets": "Ota kosketusjoukot käyttöön", + "cmd.contact_list": "Luettele kosketusjoukot", + "cmd.diagnostics_crash_report": "Kaatumisraportti", + "cmd.diagnostics_crash_reports": "Kaatumisraportit", + "cmd.diagnostics_crash_reports_clear": "Poista kaatumisraportit", + "cmd.diagnostics_crash_test": "Kaatumistesti", + "cmd.doc_recover": "Palauta malli", + "cmd.doc_recovery_discard": "Hylkää palautettu malli", + "cmd.doc_recovery_list": "Palautettavat mallit", + "cmd.document_inspect": "Tarkastele mallia", + "cmd.document_metadata": "Mallin ominaisuudet", + "cmd.document_metadata_remove": "Poista mallin ominaisuus", + "cmd.document_metadata_set": "Muuta mallin ominaisuuksia", + "cmd.document_revision": "Mallin revisio", + "cmd.document_units": "Muuta mallin yksiköitä", + "cmd.edit_checkpoint": "Tarkistuspiste", + "cmd.edit_repeat": "Toista edellinen komento", + "cmd.edit_restore_checkpoint": "Palauta tarkistuspiste", + "cmd.engine_commands": "Luettele komennot", + "cmd.engine_describe": "Kuvaile komento", + "cmd.explode_create": "Räjäytyskuva", + "cmd.explode_delete": "Poista räjäytyskuva", + "cmd.explode_list": "Luettele räjäytyskuvat", + "cmd.explode_show": "Näytä räjäytyskuva", + "cmd.expr_evaluate": "Laske lauseke", + "cmd.face_delete": "Poista pinta", + "cmd.file_export": "Vie", + "cmd.file_insert_mesh": "Lisää verkko", + "cmd.file_insert_step": "Lisää STEP", + "cmd.file_open_in_easyprint": "Avaa EasyPrintillä", + "cmd.file_save_mesh": "Tallenna verkkona", + "cmd.file_send_to_slicer": "Lähetä viipalointiohjelmaan", + "cmd.file_slicers": "Asennetut viipalointiohjelmat", + "cmd.frame_member": "Runkopalkki", + "cmd.frame_profile_insert": "Lisää profiili", + "cmd.frame_profile_list": "Profiilikirjasto", + "cmd.frame_profile_properties": "Profiilin ominaisuudet", + "cmd.inspect_accessibility": "Saavutettavuusanalyysi", + "cmd.inspect_center_of_mass": "Massakeskipiste", + "cmd.inspect_clearance": "Välys", + "cmd.inspect_colored_components": "Ota komponenttivärit käyttöön", + "cmd.inspect_colored_components_disable": "Poista komponenttivärit käytöstä", + "cmd.inspect_compare": "Vertaa kappaleita", + "cmd.inspect_continuity": "Jatkuvuusanalyysi", + "cmd.inspect_curvature_comb": "Kaarevuuskampa-analyysi", + "cmd.inspect_curvature_map": "Kaarevuuskartta-analyysi", + "cmd.inspect_draft": "Päästöanalyysi", + "cmd.inspect_environment_map": "Ympäristökartta-analyysi", + "cmd.inspect_interference": "Päällekkäisyys", + "cmd.inspect_isocurve": "Isokäyräanalyysi", + "cmd.inspect_minimum_radius": "Pienimmän säteen analyysi", + "cmd.inspect_parameter_sweep": "Parametrin läpikäynti", + "cmd.inspect_silhouette": "Ääriviiva", + "cmd.inspect_zebra": "Seepra-analyysi", + "cmd.joint_as_built": "Nykyasennon mukainen nivel", + "cmd.joint_cam": "Nokka", + "cmd.joint_create": "Nivel", + "cmd.joint_delete": "Poista nivel", + "cmd.joint_drive": "Ohjaa niveliä", + "cmd.joint_edit": "Muokkaa niveltä", + "cmd.joint_gear": "Hammaspyörä", + "cmd.joint_inspect": "Tarkastele niveltä", + "cmd.joint_limits": "Nivelen rajat", + "cmd.joint_list": "Luettele nivelet", + "cmd.joint_motion_link": "Liikelinkki", + "cmd.joint_origin": "Nivelen origo", + "cmd.joint_rack_pinion": "Hammastanko ja -pyörä", + "cmd.joint_reach": "Tavoita piste", + "cmd.joint_rigid_group": "Jäykkä ryhmä", + "cmd.joint_screw": "Ruuvi", + "cmd.joint_solve": "Ratkaise nivelet", + "cmd.joint_sweep": "Käy nivel läpi", + "cmd.library_apply": "Käytä paikallisen kirjaston kohdetta", + "cmd.library_export": "Vie mallikirjasto", + "cmd.library_inspect": "Tarkastele paikallista kirjastoa", + "cmd.material_assign": "Fysikaalinen materiaali", + "cmd.material_create": "Uusi fysikaalinen materiaali", + "cmd.material_delete": "Poista fysikaalinen materiaali", + "cmd.material_edit": "Muokkaa fysikaalista materiaalia", + "cmd.material_inspect": "Tarkastele fysikaalista materiaalia", + "cmd.material_list": "Luettele materiaalit", + "cmd.material_properties_directional": "Suuntakohtainen kimmokerroin", + "cmd.material_properties_validate": "Tarkista fysikaalisen materiaalin tiedot", + "cmd.mesh_convert": "Muunna verkko", + "cmd.model_edges": "Luettele särmät", + "cmd.model_faces": "Luettele pinnat", + "cmd.model_threads": "Luettele kierteet", + "cmd.motion_delete": "Poista liiketutkimus", + "cmd.motion_export": "Vie liiketutkimus", + "cmd.motion_export_animation": "Vie liikeanimaatio", + "cmd.motion_interference": "Liikkeen päällekkäisyys", + "cmd.motion_list": "Luettele liiketutkimukset", + "cmd.motion_play": "Toista liiketutkimus", + "cmd.motion_plot": "Piirrä liiketutkimuksen kuvaaja", + "cmd.motion_study": "Liiketutkimus", + "cmd.motion_trace": "Liikerata", + "cmd.motion_trace_edit": "Muokkaa liikerataa", + "cmd.motion_trace_keep": "Säilytä liikerata", + "cmd.occurrence_align": "Kohdista esiintymä", + "cmd.occurrence_copy": "Liitä (esiintymä)", + "cmd.occurrence_drag": "Vedä esiintymää", + "cmd.occurrence_ground": "Kiinnitä maahan", + "cmd.occurrence_move": "Siirrä esiintymää", + "cmd.occurrence_properties": "Esiintymän fysikaaliset ominaisuudet", + "cmd.occurrence_reparent": "Siirrä komponenttiin", + "cmd.parameters_add": "Lisää käyttäjäparametri", + "cmd.parameters_change": "Muuta parametreja", + "cmd.parameters_comment": "Kommentoi parametria", + "cmd.parameters_complete": "Täydennä lauseke", + "cmd.parameters_delete": "Poista parametri", + "cmd.parameters_export": "Vie parametrit", + "cmd.parameters_favorite": "Suosikkiparametri", + "cmd.parameters_graph": "Parametrikaavio", + "cmd.parameters_import": "Tuo parametrit", + "cmd.parameters_list": "Luettele parametrit", + "cmd.parameters_move": "Siirrä parametri", + "cmd.parameters_rename": "Nimeä parametri uudelleen", + "cmd.parameters_users": "Parametrin käyttäjät", + "cmd.parts_fastener": "Lisää kiinnitin", + "cmd.parts_insert": "Lisää osa", + "cmd.parts_library": "Vakio-osat", + "cmd.parts_resize": "Muuta osan kokoa", + "cmd.plastic_assign_rule": "Määritä muovisääntö", + "cmd.plastic_boss": "Tappi", + "cmd.plastic_lip": "Huuli", + "cmd.plastic_manage_rules": "Hallitse muovisääntöjä", + "cmd.plastic_rest": "Tuki", + "cmd.plastic_rules": "Luettele muovisäännöt", + "cmd.plastic_snap_fit": "Napsautusliitos", + "cmd.pointcloud_delete": "Poista pistepilvi", + "cmd.pointcloud_edit": "Muokkaa pistepilveä", + "cmd.pointcloud_insert": "Lisää pistepilvi", + "cmd.pointcloud_list": "Pistepilvet", + "cmd.render_aging_describe": "Kerroksellinen pinnan vanhentaminen", + "cmd.render_backends": "Renderöintimoottorit", + "cmd.render_batch": "Renderöi kaikki kuvakulmat", + "cmd.render_camera_fit": "Rajaa renderöinnin kohde", + "cmd.render_camera_get": "Tarkastele renderöintikameraa", + "cmd.render_cancel": "Peruuta renderöinti", + "cmd.render_compare": "Vertaa kuvia", + "cmd.render_effects_describe": "Linssi- ja luovat tehosteet", + "cmd.render_export": "Vie renderöity kuva", + "cmd.render_hdr_inspect": "Tarkastele HDR-lähdettä", + "cmd.render_library_appearance_apply": "Käytä kirjaston läpinäkymätöntä ulkoasua", + "cmd.render_library_describe": "Tarkastele renderöintikirjaston resurssia", + "cmd.render_library_environment_apply": "Käytä sisäänrakennettua HDR-ympäristöä", + "cmd.render_library_list": "Selaa renderöintikirjastoa", + "cmd.render_library_physical_prepare": "Valmistele fysikaalinen viite", + "cmd.render_library_preview": "Tarkastele alkuperäistä pintakarttaa", + "cmd.render_library_studio_apply": "Käytä laadittuja studiovaloja", + "cmd.render_library_studio_prepare": "Valmistele laaditut studiovalot", + "cmd.render_library_surface_bind": "Tallenna pintasidonta", + "cmd.render_library_surface_prepare": "Valmistele laaditut pintakartat", + "cmd.render_library_surface_unbind": "Poista pintasidonta", + "cmd.render_library_targets": "Etsi renderöintikirjaston kohteet", + "cmd.render_lights_add": "Lisää valo", + "cmd.render_lights_duplicate": "Monista renderöintivalo", + "cmd.render_lights_list": "Luettele renderöintivalot", + "cmd.render_lights_mute": "Mykistä valo", + "cmd.render_lights_remove": "Poista renderöintivalo", + "cmd.render_lights_set": "Aseta renderöintivalo", + "cmd.render_lights_solo": "Vain tämä valo", + "cmd.render_optics_bind": "Tallenna optinen sidonta", + "cmd.render_optics_unbind": "Poista optinen sidonta", + "cmd.render_output_size": "Aseta renderöinnin tulostekoko", + "cmd.render_preflight": "Tarkista renderöintipyyntö", + "cmd.render_preflight_copy": "Kopioi renderöinnin tarkistusraportti", + "cmd.render_preset_apply": "Käytä natiivistudiota", + "cmd.render_preset_resolve": "Tarkista natiivistudio", + "cmd.render_quality_prepare": "Tarkista kiinteä näytteistysbudjetti", + "cmd.render_result_close": "Sulje renderöity kuva", + "cmd.render_result_copy": "Kopioi renderöity kuva", + "cmd.render_result_pin": "Säilytä näytetty renderöintitulos", + "cmd.render_result_regrade": "Sävytä lineaarinen master uudelleen", + "cmd.render_result_view": "Näytä renderöity kuva", + "cmd.render_scene": "Tarkastele renderöintinäkymää", + "cmd.render_settings_apply": "Käytä renderöintikameraa", + "cmd.render_settings_capture_hdr": "Tallenna renderöintiasetukset HDR:n kanssa", + "cmd.render_settings_get": "Renderöintiasetukset", + "cmd.render_settings_set": "Tallenna renderöintiasetukset", + "cmd.render_shot_duplicate": "Monista renderöinnin kuvakulma", + "cmd.render_shot_remove": "Poista renderöinnin kuvakulma", + "cmd.render_shot_rename": "Nimeä renderöinnin kuvakulma uudelleen", + "cmd.render_stage_backdrop": "Tallenna fysikaalinen renderöintitausta", + "cmd.render_stage_clear": "Poista renderöintilava", + "cmd.render_stage_ground": "Tallenna renderöinnin maapinta", + "cmd.render_start": "Renderöi kuva", + "cmd.render_status": "Renderöinnin edistyminen", + "cmd.select_clear": "Tyhjennä valinta", + "cmd.settings_profile_apply": "Käytä asetusprofiilia", + "cmd.settings_profile_delete": "Poista asetusprofiili", + "cmd.settings_profile_export": "Vie asetusprofiili", + "cmd.settings_profile_import": "Tuo asetusprofiili", + "cmd.settings_profile_list": "Asetusprofiilit", + "cmd.settings_profile_rename": "Nimeä asetusprofiili uudelleen", + "cmd.settings_profile_save": "Tallenna asetusprofiili", + "cmd.sheet_convert": "Muunna ohutlevyksi", + "cmd.sheet_export_bend_table": "Vie taivutustaulukko", + "cmd.sheet_export_dxf": "Vie levityskuva DXF-muodossa", + "cmd.sheet_flange": "Laippa", + "cmd.sheet_flat_pattern": "Luo levityskuva", + "cmd.sheet_fold": "Taita", + "cmd.sheet_hem": "Huulos", + "cmd.sheet_manage_rules": "Ohutlevysäännöt", + "cmd.sheet_path_flange": "Polkulaippa", + "cmd.sheet_perforate": "Rei'itä", + "cmd.sheet_refold": "Taita uudelleen", + "cmd.sheet_rules": "Luettele ohutlevysäännöt", + "cmd.sheet_unfold": "Avaa", + "cmd.sketch.construction_line": "Apuviiva", + "cmd.sketch.construction_line.angle": "Apuviiva kulmassa", + "cmd.sketch.construction_line.bisector": "Kulman puolittaja", + "cmd.sketch.construction_line.horizontal": "Vaakasuora apuviiva", + "cmd.sketch.construction_line.parallel": "Yhdensuuntainen apuviiva", + "cmd.sketch.construction_line.perpendicular": "Kohtisuora apuviiva", + "cmd.sketch.construction_line.tangent_at": "Tangentti käyrän pisteessä", + "cmd.sketch.construction_line.tangent_external": "Tangentti ulkoisen pisteen kautta", + "cmd.sketch.construction_line.tangent_two": "Yhteinen tangentti", + "cmd.sketch.construction_line.vertical": "Pystysuora apuviiva", + "cmd.sketch_arc3d": "3D-kaari", + "cmd.sketch_arc_center_point": "Keskipistekaari", + "cmd.sketch_arc_tangent": "Tangenttikaari", + "cmd.sketch_arc_three_point": "3 pisteen kaari", + "cmd.sketch_auto_constrain": "Automaattirajoitus", + "cmd.sketch_auto_constrain_from_datum": "Automaattirajoitus perustasosta", + "cmd.sketch_auto_project": "Projisoi särmät automaattisesti viitteelle", + "cmd.sketch_blend_curve": "Liukukäyrä", + "cmd.sketch_cancel": "Peruuta luonnos", + "cmd.sketch_chain": "Ketju", + "cmd.sketch_chamfer_distance_angle": "Etäisyys ja kulma -viiste", + "cmd.sketch_chamfer_equal_distance": "Yhtä suurten etäisyyksien viiste", + "cmd.sketch_chamfer_two_distance": "Kahden etäisyyden viiste", + "cmd.sketch_circle_center": "Keskipiste ja halkaisija -ympyrä", + "cmd.sketch_circle_three_point": "3 pisteen ympyrä", + "cmd.sketch_circle_three_tangent": "3 tangentin ympyrä", + "cmd.sketch_circle_two_point": "2 pisteen ympyrä", + "cmd.sketch_circle_two_tangent": "2 tangentin ympyrä", + "cmd.sketch_constrainer": "Luonnoksen rajoitin", + "cmd.sketch_constraint_fix": "Kiinnitä/vapauta", + "cmd.sketch_constraint_midpoint": "Keskipiste", + "cmd.sketch_construction": "Normaali/apu", + "cmd.sketch_copy": "Kopioi luonnoksen entiteetit", + "cmd.sketch_cut": "Leikkaa luonnoksen entiteetit", + "cmd.sketch_delete": "Poista luonnoksen entiteetit", + "cmd.sketch_dimension_text": "Siirrä mittatekstiä", + "cmd.sketch_drag": "Vedä luonnosgeometriaa", + "cmd.sketch_drag_end": "Lopeta luonnoksen veto", + "cmd.sketch_draw_dimensioned": "Piirrä mitoitettua luonnosgeometriaa", + "cmd.sketch_drawing_bounds": "Piirustuksen koko", + "cmd.sketch_edit": "Muokkaa luonnosta", + "cmd.sketch_edit_text": "Muokkaa tekstiä", + "cmd.sketch_equation_curve": "Yhtälökäyrä", + "cmd.sketch_equation_curve_edit": "Muokkaa yhtälökäyrää", + "cmd.sketch_export_dxf": "Tallenna DXF-muodossa", + "cmd.sketch_finish_auto_constrain": "Lopeta automaattirajoituksella", + "cmd.sketch_fit_curves_to_section": "Sovita käyrät verkon leikkaukseen", + "cmd.sketch_glyphs": "Rajoitesymbolit", + "cmd.sketch_include_3d": "Sisällytä 3D-geometria", + "cmd.sketch_infinite": "Ääretön viiva", + "cmd.sketch_insert_dxf": "Lisää DXF", + "cmd.sketch_insert_svg": "Lisää SVG", + "cmd.sketch_inspect": "Tarkastele luonnosta", + "cmd.sketch_intersection_curve": "Leikkauskäyrä", + "cmd.sketch_isoparametric_curve": "Isoparametrinen käyrä", + "cmd.sketch_line3d": "3D-viiva", + "cmd.sketch_line_midpoint": "Keskipisteviiva", + "cmd.sketch_loose_ends": "Irralliset päät", + "cmd.sketch_loose_ends_tie": "Yhdistä irralliset päät", + "cmd.sketch_move": "Siirrä/kopioi luonnoksen entiteetit", + "cmd.sketch_move3d": "Siirrä 3D-pistettä", + "cmd.sketch_move_point": "Vedä luonnospistettä", + "cmd.sketch_offset_break": "Katkaise siirtymän yhteys", + "cmd.sketch_options": "Luonnospaletin asetukset", + "cmd.sketch_paste": "Liitä luonnoksen entiteetit", + "cmd.sketch_point3d": "3D-piste", + "cmd.sketch_polygon_circumscribed": "Ympäri piirretty monikulmio", + "cmd.sketch_polygon_edge": "Sivun mukainen monikulmio", + "cmd.sketch_polygon_inscribed": "Sisään piirretty monikulmio", + "cmd.sketch_project_to_surface": "Projisoi pinnalle", + "cmd.sketch_rectangle_center": "Keskipistesuorakulmio", + "cmd.sketch_rectangle_rounded": "Pyöristetty suorakulmio", + "cmd.sketch_rectangle_three_point": "3 pisteen suorakulmio", + "cmd.sketch_rectangle_two_point": "2 pisteen suorakulmio", + "cmd.sketch_redefine": "Määritä luonnostaso uudelleen", + "cmd.sketch_slot_arc_center": "Keskipistekaariura", + "cmd.sketch_slot_arc_three_point": "Kolmen pisteen kaariura", + "cmd.sketch_slot_center_point": "Keskipisteura", + "cmd.sketch_slot_center_to_center": "Keskeltä keskelle -ura", + "cmd.sketch_slot_overall": "Kokonaismitan ura", + "cmd.sketch_snap": "Tartuntapäättely", + "cmd.sketch_solve": "Ratkaise luonnos", + "cmd.sketch_spline3d": "3D-splini", + "cmd.sketch_spline_control_point": "Ohjauspistesplini", + "cmd.sketch_spline_control_point_5": "Ohjauspistesplini (aste 5)", + "cmd.sketch_spline_fit_point": "Sovituspistesplini", + "cmd.sketch_spline_insert_point": "Lisää splinipiste", + "cmd.sketch_spline_reverse": "Käännä splinin suunta", + "cmd.sketch_spun_profile": "Pyöräytysprofiili", + "cmd.sketch_toggle_diameter": "Vaihda säde / halkaisija", + "cmd.sketch_toggle_driven": "Vaihda ohjattu mitta", + "cmd.sketch_transform": "Muunna", + "cmd.sketch_trim_path": "Katkaise polkua pitkin", + "cmd.solid_align": "Kohdista", + "cmd.solid_boundary_fill": "Rajatäyttö", + "cmd.solid_bounding_solid": "Ympäröivä kappale", + "cmd.solid_emboss": "Kohokuvio", + "cmd.solid_move": "Siirrä/kopioi", + "cmd.solid_offset_face": "Pinnan siirtymä", + "cmd.solid_press_pull": "Työnnä/vedä", + "cmd.solid_remove": "Poista", + "cmd.solid_replace_face": "Korvaa pinta", + "cmd.solid_rib": "Ripa", + "cmd.solid_split_face": "Jaa pinta", + "cmd.solid_web": "Uuma", + "cmd.surface_blend": "Sovituspinta", + "cmd.surface_boundary": "Reunapinta", + "cmd.surface_extrude": "Pintapursotus", + "cmd.surface_fair": "Silota pinta", + "cmd.surface_fillet": "Pintapyöristys", + "cmd.surface_loft": "Pintalofttaus", + "cmd.surface_match": "Sovita pinta", + "cmd.surface_offset": "Siirtymäpinta", + "cmd.surface_patch": "Paikkapinta", + "cmd.surface_reverse_normal": "Käännä normaali", + "cmd.surface_revolve": "Pintapyöräytys", + "cmd.surface_ruled": "Viivapinta", + "cmd.surface_stitch": "Ompele", + "cmd.surface_sweep": "Pintapyyhkäisy", + "cmd.surface_thicken": "Paksunna", + "cmd.surface_unstitch": "Pura ommel", + "cmd.surface_untrim": "Poista katkaisu", + "cmd.timeline_add": "Lisää tallennettu piirre", + "cmd.timeline_compute_all": "Laske kaikki", + "cmd.timeline_copy": "Kopioi piirteet", + "cmd.timeline_dependents": "Piirteestä riippuvat", + "cmd.timeline_export_script": "Vie komentoskripti", + "cmd.timeline_inspect": "Tarkastele piirrettä", + "cmd.timeline_paste": "Liitä piirteet", + "cmd.timeline_play": "Toista historia", + "cmd.timeline_redefine": "Muokkaa piirrettä", + "cmd.timeline_rename": "Nimeä uudelleen", + "cmd.timeline_reorder": "Järjestä piirre uudelleen", + "cmd.timeline_roll_to": "Siirrä historiamerkkiä", + "cmd.timeline_rollback": "Siirrä aikajanan merkkiä", + "cmd.timeline_stop": "Pysäytä historia", + "cmd.timeline_suppress": "Ohita piirre", + "cmd.view.render_browser": "Renderöintiselain", + "cmd.view.render_dock": "Siirrä renderöintitelakkaa", + "cmd.view.render_focus": "Renderöinnin kohdistusnäkymä", + "cmd.view.status_items": "Näkymäikkunan tilakohteet", + "cmd.view_ambient_occlusion": "Ympäristön peittovarjostus", + "cmd.view_angle_readout": "Näkymän kulmat", + "cmd.view_apply": "Palauta nimetty näkymä", + "cmd.view_back": "Takanäkymä", + "cmd.view_bottom": "Alanäkymä", + "cmd.view_delete": "Poista nimetty näkymä", + "cmd.view_fit": "Sovita", + "cmd.view_front": "Etunäkymä", + "cmd.view_graphics_preset": "Grafiikan esiasetus", + "cmd.view_grid": "Ruudukko", + "cmd.view_ground_shadow": "Maavarjo", + "cmd.view_home": "Aloitusnäkymä", + "cmd.view_isometric": "Isometrinen näkymä", + "cmd.view_left": "Vasen näkymä", + "cmd.view_list": "Luettele nimetyt näkymät", + "cmd.view_navigate": "Navigoi näkymää", + "cmd.view_navigation_bar": "Navigointipalkki", + "cmd.view_next_tab": "Seuraava välilehti", + "cmd.view_normal_to": "Kohtisuoraan valintaan", + "cmd.view_previous_tab": "Edellinen välilehti", + "cmd.view_rename": "Nimeä nimetty näkymä uudelleen", + "cmd.view_render_frame": "Renderöintikehyksen apuviivat", + "cmd.view_right": "Oikea näkymä", + "cmd.view_save": "Uusi nimetty näkymä", + "cmd.view_shots_strip": "Kuvakulmanauha", + "cmd.view_spin": "Jatkuva kierto", + "cmd.view_tool": "Navigointityökalu", + "cmd.view_top": "Ylänäkymä", + "cmd.view_visual_style": "Visuaalinen tyyli", + "color.copy": "Kopioi väriarvot napsauttamalla", + "color.edit": "Muokkaa väriä napsauttamalla", + "color.gamma": "Väriarvot näytetään gamma-avaruudessa 0-255", + "color.hex": "Heksa", + "color.hex_help": "Syötä #rrggbb tai #rrggbbaa; kuusi numeroa säilyttää peittävyyden", + "color.hex_hint": "#rrggbb", + "color.hue": "Sävy", + "color.linear": "Väriarvot näytetään lineaarisessa avaruudessa 0-1", + "color.saturation_value": "Kylläisyys ja kirkkaus", + "color.selected": "Valittu väri", + "config.column_filter_empty": "Ei vastaavia sarakkeita", + "contact.components_count": "{count} komponenttia", + "contact.enabled_label": "käytössä", + "coupling.gear_1": "Hammaspyörä 1", + "coupling.gear_1_pick": "Hammaspyörä 1 (valinnainen)", + "coupling.gear_2": "Hammaspyörä 2", + "coupling.gear_2_pick": "Hammaspyörä 2 (valinnainen)", + "coupling.internal": "Sisähammaspyörä", + "coupling.needs_turn_and_slide": "tarvitsee yhden kiertyvän ja yhden liukuvan nivelen", + "coupling.needs_turns": "tarvitsee kaksi kiertyvää niveltä", + "coupling.not_round": "ei ole pyöreä pinta", + "coupling.nut": "Mutteri", + "coupling.picked": "valittu", + "coupling.pinion": "Pinioni", + "coupling.pinion_pick": "Pinioni (valinnainen)", + "coupling.pitch": "Nousu", + "coupling.rack": "Hammastanko", + "coupling.radius_1": "Säde 1", + "coupling.radius_2": "Säde 2", + "crash.announce": "SolidCraft sulkeutui viime kerralla odottamatta tai kohtasi sisäisen virheen. Kaatumisraportti tallennettiin (Ohje > Kaatumisraportit…).", + "crash.copy_folder": "Kopioi kansion polku", + "crash.copy_path": "Kopioi polku", + "crash.deleted": "Poistettiin {count} raporttia.", + "crash.detected_time": "Havaittu {time} UTC", + "crash.intro": "Kaatumisraportit pysyvät tällä tietokoneella, eikä niitä lähetetä minnekään. Raportoi… avaa luonnoksen, jonka tarkistat ennen kuin mitään jaetaan.", + "crash.kind_command": "Sisäinen virhe komennossa", + "crash.kind_internal": "Sisäinen virhe", + "crash.kind_kernel": "Ytimen virhe (palautettu)", + "crash.kind_unclean": "Sulkeutui odottamatta", + "crash.previous_session": "Aiempi SolidCraft-istunto päättyi odottamatta tai kohtasi sisäisen virheen. Hallitsemattomat lopetukset havaitaan käynnistyksessä. Kohdasta Ohje > Kaatumisraportit… näet lisätietoja.", + "crash.replayable": "toistettavissa", + "crash.report": "Raportoi…", + "crash.unclean_explanation": "Havaittu myöhemmässä käynnistyksessä; tarkkaa lopetusaikaa ei tallennettu. Myös järjestelmän sammutus tai pakotettu pysäytys voi aiheuttaa hallitsemattoman lopetuksen.", + "delete.and_more": "… ja {count} muuta", + "diagnostic.empty_boolean_component": "komponentissa '{component}' ei ole kappaletta, josta leikata tai jonka kanssa muodostaa leikkaus; työkalu saattaa olla päällekkäin kappaleen '{body}' kanssa komponentissa '{other}' – aktivoi kyseinen komponentti tai valitse kappale kohteeksi", + "dialog.bodies_count": "{count} kappaletta", + "dialog.clearance_invalid": "välyksen on oltava pituus, joka on vähintään 0", + "dialog.compute": "Laske", + "dialog.compute_again": "Laske uudelleen", + "dialog.computing": "Lasketaan… {seconds} s", + "dialog.could_not_be_checked": "ei voitu tarkistaa", + "dialog.custom_axis": "↖ Mukautettu akseli", + "dialog.dimension_insert_hint": "Lisää toisen ohjaavan mitan nimi napsauttamalla sitä", + "dialog.dimension_name": "Mitta: {name}", + "dialog.edit_title": "MUOKKAA {title}", + "dialog.no_shown_body": "Ei näkyvää kappaletta muutettavaksi: tuo jokin näkyviin tai valitse Uusi kappale", + "dialog.select": "↖ Valitse", + "dialog.selected_count": "↖ {count} valittu", + "dialog.selection_guidance": "Valitse syöte muuttaaksesi sitä. Poista valittuja kohteita napsauttamalla niitä; myös Ctrl/Cmd-napsautus vaihtaa valintaa. Tarkista esikatselu ja paina sitten OK.", + "dialog.selection_step": "{step}/{total}: Valitse {input}", + "dialog.step": "Vaihe", + "document.changed_on_disk": "{name} muuttui levyllä", + "document.changed_on_disk_detail": "Toinen ohjelma tai tietokone on muuttanut tiedostoa {name} sen jälkeen, kun avasit tai viimeksi tallensit sen. Korvaa vaihtaa ne muutokset omiisi; Tallenna kopio säilyttää molemmat.", + "document.copy_name": "{name} kopio", + "document.open_anyway": "Avaa silti", + "document.open_elsewhere": "{name} on jo auki", + "document.open_elsewhere_detail": "{name} on auki SolidCraftissa {app} käyttäjällä {who}, avattu {when}. Jos tallennatte molemmat, toisen työ menetetään. Avaa se vain luku -tilassa, niin voit katsoa sitä tallentamatta sen päälle.", + "document.open_read_only": "Avaa vain luku -tilassa", + "document.opened_read_only": "Avattu vain luku -tilassa: tallennus kysyy uutta tiedostoa", + "document.overwrite": "Korvaa", + "document.read_only_save": "Tämä malli avattiin vain luku -tilassa: tallenna se uutena tiedostona", + "document.save_changes": "Tallennetaanko muutokset tiedostoon {name}?", + "document.save_copy": "Tallenna kopio…", + "document.unsaved_changes": "Tiedostossa {name} on tallentamattomia muutoksia. Jos et tallenna, ne menetetään.", + "drive.rigid": "{joint} on jäykkä nivel: se pitää osansa paikallaan, joten sillä ei ole mitään ohjattavaa. Jos haluat niiden liikkuvan, vaihda sen tyyppi kohdassa Muokkaa niveltä: Kiertyvä kiertoon, Liukuva liukumiseen, Sylinterimäinen molempiin.", + "drive.stopped_by_contact": "Pysähtyi arvoon {value}: {a} kulkisi kohteen {b} läpi", + "drive.stopped_by_loop": "Pysähtyi arvoon {value}: nivelen {joint} kautta kulkeva silmukka ei voi sulkeutua enempää", + "drive.unconnected": "Ei liikuta mitään: {part} ei ole yhteydessä mihinkään maadoitettuun", + "dxf.cancel_wait": "Odotetaan viennin pysähtymistä…", + "dxf.cancelled": "DXF-vienti peruttu", + "dxf.cancelling": "Perutaan DXF-vientiä", + "dxf.exporting": "Viedään DXF", + "dxf.invalid_tolerance": "Käyrätoleranssin on oltava välillä 0.0001–1 mm.", + "dxf.progress.index": "Valmistellaan pintoja (2/5)", + "dxf.progress.meshes": "Valmistellaan verkkoja (1/5)", + "dxf.progress.occlusion": "Selvitetään piiloviivoja (4/5)", + "dxf.progress.projection": "Projisoidaan geometriaa (3/5)", + "dxf.progress.write": "Kirjoitetaan DXF (5/5)", + "dxf.tolerance": "Käyrätoleranssi", + "dxf.tolerance_help": "Vain näkyvät kappaleet ja näytetyt luonnokset. Käyrät muodostetaan suorista osista. Pienempi toleranssi antaa tarkemmat käyrät ja vie enemmän aikaa ja muistia.", + "each rail's curves must be in one sketch": "kunkin ohjauskäyrän käyrien on oltava samassa luonnoksessa", + "feature computation ended without a result": "piirteen laskenta päättyi ilman tulosta", + "file.open.drawing_filter": "Mallit, CAD-tiedostot, verkot ja piirustukset", + "file_load.built": "Rakennettu tähän mennessä: {built}/{total} piirrettä.", + "file_load.converting": "Muunnetaan kappaletta {done}/{total}", + "file_load.inserting": "Lisätään geometriaa", + "file_load.link_item": "{name} ({done}/{total})", + "file_load.opening": "Avataan mallia", + "file_load.preparing_thumbnail": "Valmistellaan pikkukuvaa...", + "file_load.preparing_view": "Valmistellaan näkymää...", + "file_load.preview_note": "Tallennettu esikatselu. Geometriaa tarkistetaan ennen muokkausta.", + "file_load.reading": "Luetaan tiedostoa...", + "file_load.reading_percent": "Luetaan tiedostoa... {percent} %", + "file_load.rebuilding": "Rakennetaan uudelleen piirrettä {done}/{total}", + "file_load.recovering": "Palautetaan mallia", + "file_load.stopping": "Odotetaan edellisen toiminnon pysähtymistä...", + "file_load.updating_links": "Päivitetään linkkejä", + "file_load.waiting": "Odotetaan tiedostotoimintoa", + "fits the face, e.g. M8": "sopii pintaan, esim. M8", + "from the profile": "profiilista", + "from the rule": "säännöstä", + "help.apache": "Apache-lisenssi 2.0", + "help.appearance.assign": "Antaa valituille kappaleille, pinnoille tai komponenteille värin tai materiaalin ulkoasun muuttamatta niiden painoa. Käytä sitä osien erottamiseen toisistaan tai pintakäsittelyn esikatseluun.", + "help.attributions": "Tekijätiedot (ATTRIBUTION.md)", + "help.build": "Versio {version} · commit {commit} · koottu {date}", + "help.component.finish_edit": "Tallentaa paikallaan muokattavan osan, palaa sen kokoonpanoon ja päivittää kokoonpanon sen mukaan. Käytä sitä, kun linkitetyn osan muutokset ovat valmiit.", + "help.connect_note": "Käynnistä kukin avustaja uudelleen, jotta SolidCraft-palvelin latautuu. Jokaisesta tiedostosta otettiin varmuuskopio .bak ennen muutosta.", + "help.connect_title": "Yhdistä tekoälyavustajat", + "help.face.delete": "Poistaa valitut pinnat ja antaa viereisten pintojen sulkea aukon tai jättää aukon auki pintana. Käytä sitä poistamaan tiellä oleva pyöristetty särmä, reikä tai napa.", + "help.font_details": "Käyttöliittymä käyttää eguin sisäänrakennettuja fontteja (Ubuntu Light ja Hack, Ubuntu Font Licence- ja MIT/Bitstream Vera -lisenssein; Noto Emoji ja emoji-kuvakefontti, OFL- ja MIT-lisenssein). Luonnosteksti käyttää Liberation Sans -fonttia (SIL OFL 1.1). Crafting Appsin valinnaista fonttipakettia craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1) ei sisällytetä SolidCraft-koontiversioihin.", + "help.fonts": "Fontit", + "help.licences": "Lisenssit", + "help.material.assign": "Määrittää, mistä valitut kappaleet on tehty, kuten teräs, alumiini tai ABS. Käytä sitä, jotta massa ja paino vastaavat todellista osaa.", + "help.mit": "MIT-lisenssi", + "help.notice": "NOTICE", + "help.occurrence.move": "Siirtää kokoonpanon osaa vain sen nivelten sallimia vapausasteita pitkin. Valitse osan pinta ja vedä sitten käytettävissä olevaa nuolta tai kaarta.", + "help.parameters.change": "Avaa taulukon nimetyistä arvoista ja lausekkeista, jotka ohjaavat mallin mittoja. Käytä sitä muuttamaan kokoa yhdessä paikassa niin, että jokainen sitä käyttävä piirre seuraa.", + "help.pointcloud.insert": "Lisää PLY-, XYZ- tai PTS-skannaus pistepilvenä mallin rinnalle. Käytä sitä apuna, kun rakennat mallia mitatusta datasta.", + "help.sketch.construction_line": "Luo kahden pisteen kautta kulkeva ääretön apuviiva. Käytä sitä luonnoksen asetteluun jakamatta profiileja.", + "help.sketch.construction_line.angle": "Luo ääretön apuviiva kulmaan luonnoksen X-akselista. Valitse piste ja kirjoita kulma tai valitse sen suunta.", + "help.sketch.construction_line.bisector": "Valitse kaksi leikkaavaa suoraa luonnosviivaa ja valitse sitten jompikumpi kulmanpuolittaja. Tulos seuraa lähdeviivojaan.", + "help.sketch.construction_line.horizontal": "Luo pisteen kautta kulkeva ääretön vaakasuora apuviiva. Sen vaakasuoruusrajoite seuraa myöhempiä muokkauksia.", + "help.sketch.construction_line.parallel": "Valitse suora luonnosviiva ja sijoita sitten sen kanssa yhdensuuntainen ääretön apuviiva. Valitse toinen referenssi napsauttamalla hiiren oikeaa painiketta.", + "help.sketch.construction_line.perpendicular": "Valitse suora luonnosviiva ja sijoita sitten sitä vastaan kohtisuora ääretön apuviiva. Valitse toinen referenssi napsauttamalla hiiren oikeaa painiketta.", + "help.sketch.construction_line.tangent_at": "Luo tangentti ympyrän tai ympyränkaaren pisteeseen tai avoimen splinin tai kartioleikkauskäyrän päätepisteeseen. Valitse käyrä ja sitten sen kosketuspiste.", + "help.sketch.construction_line.tangent_external": "Valitse ympyrä tai ympyränkaari, sitten ulkopuolinen piste ja tangenttiratkaisu. Esikatselut näyttävät vain piirretyllä kaarella olevat kosketuskohdat.", + "help.sketch.construction_line.tangent_two": "Valitse kaksi ympyrää tai ympyränkaarta ja valitse sitten yhteinen tangentti esikatselusta. Kosketuskohtien on oltava piirretyillä käyrillä.", + "help.sketch.construction_line.vertical": "Luo pisteen kautta kulkeva ääretön pystysuora apuviiva. Sen pystysuoruusrajoite seuraa myöhempiä muokkauksia.", + "help.sketch.equation_curve": "Piirtää käyrän kaavoista: x(t) ja y(t) tai y = f(x) annetulla välillä. Kaavoissa voi käyttää parametreja, ja käyrä seuraa niitä: käytä sitä siniaaltoon, spiraaliin tai hampaan evolventtiin.", + "help.sketch.inspect": "Avaa raportin avoimesta luonnoksesta: montako vapausastetta on jäljellä ja onko se täysin määritelty. Käytä sitä selvittämään, mikä vielä liikkuu, ennen kuin viimeistelet luonnoksen.", + "help.solid.align": "Siirtää valittuja kappaleita niin, että niiden piste, pinta tai taso kohtaa pisteen, pinnan tai tason muualla. Käytä sitä sijoittamaan osa toista vasten mittaamatta siirtymää.", + "help.solid.chamfer": "Leikkaa valittuihin särmiin asettamasi etäisyyden suoran viisteen. Käytä sitä terävien särmien katkaisuun tai helpottamaan tapin tai ruuvin sisäänmenoa.", + "help.solid.combine": "Yhdistää kappaleita, leikkaa ne pois kohdekappaleesta tai säilyttää vain niiden yhteisen tilavuuden, ja voi säilyttää työkalukappaleet. Käytä sitä osien yhdistämiseen, muodon leikkaamiseen toisella tai sen selvittämiseen, missä kaksi kappaletta ovat päällekkäin.", + "help.solid.draft": "Kallistaa valittuja pintoja asettamasi kulman verran neutraalitasosta mitattuna. Käytä sitä valetuissa tai muotissa tehdyissä osissa, jotta ne irtoavat muotista.", + "help.solid.extrude": "Työntää luonnosprofiilin 3D:ksi annetun matkan verran, lisäten tai poistaen materiaalia. Käytä sitä tasaisen ääriviivan muuttamiseen kappaleeksi, navaksi tai taskuksi.", + "help.solid.fillet": "Pyöristää valitut särmät tasaisella, asettamasi säteen ympyräpyöristyksellä. Käytä sitä pehmentämään teräviä särmiä lujuuden, mukavuuden tai ulkonäön vuoksi.", + "help.solid.hole": "Poraa asettamasi halkaisijan pyöreän reiän valittuun kohtaan pinnalla. Käytä sitä ruuvien välysrei'ille, tapeille ja muille yksinkertaisille pyöreille aukoille.", + "help.solid.loft": "Muodostaa tasaisen kappaleen kahden tai useamman luonnosprofiilin välille tai profiilista pisteeseen. Käytä sitä siirtymiin, suppiloihin, kahvoihin ja muihin muotoihin, joiden poikkileikkaus muuttuu pituuden mukaan.", + "help.solid.mirror": "Tekee valituista kappaleista tai piirteistä peilikopion tason suhteen ja voi yhdistää sen alkuperäiseen. Käytä sitä symmetrisiin osiin, jolloin mallinnat yhden puolikkaan ja saat molemmat.", + "help.solid.move": "Siirtää tai kiertää valittuja kappaleita tai niiden kopiota asettamasi etäisyyden ja kulman verran. Käytä sitä kappaleen uudelleensijoittamiseen tai toisen kappaleen tekemiseen toiseen paikkaan.", + "help.solid.offset_face": "Työntää valittuja pintoja sisään tai ulos asettamasi etäisyyden verran, ja ympäröivät pinnat seuraavat. Käytä sitä tekemään seinästä paksumpi, taskusta syvempi tai navasta korkeampi muokkaamatta luonnosta.", + "help.solid.pattern.circular": "Toistaa valittuja kohteita akselin ympäri asettamallasi määrällä ja kokonaiskulmalla. Käytä sitä pulttiympyröihin, puolaan, ripoihin ja hampaisiin.", + "help.solid.pattern.rectangular": "Toistaa valittuja kohteita yhteen tai kahteen suuntaan asettamallasi määrällä ja välillä. Käytä sitä reikien, ripojen, tappien tai jalkojen riveihin ja ruudukoihin.", + "help.solid.press_pull": "Lisää tai poistaa materiaalia, kun vedät pintaa, tai pyöristää särmiä, kun vedät niitä. Käytä sitä nopeisiin muutoksiin suoraan mallissa.", + "help.solid.remove": "Poistaa valitut kappaleet mallista tästä aikajanan kohdasta eteenpäin; aiemmat vaiheet säilyttävät ne. Käytä sitä apukappaleen, kuten tarpeettoman leikkaustyökalun, poistamiseen.", + "help.solid.replace_face": "Siirtää valitut tasopinnat tasolle tai yhdensuuntaiselle pinnalle, ja kappale kasvaa tai kutistuu sen mukaan. Käytä sitä tuomaan pinta samaan tasoon toisen osan tai aputason kanssa.", + "help.solid.revolve": "Pyöräyttää luonnosprofiilin akselin ympäri pyörähdyskappaleeksi. Käytä sitä akseleihin, nuppeihin, kulhoihin ja muihin keskiviivan suhteen pyöreisiin osiin.", + "help.solid.scale": "Suurentaa tai pienentää valittuja kappaleita kertoimella, joka on sama kaikkiin suuntiin tai oma kullekin akselille. Käytä sitä tuodun muodon koon muuttamiseen tai kutistumisen huomioimiseen.", + "help.solid.shell": "Koverraa kappaleen ontoksi jättäen asettamasi paksuiset seinämät ja voi avata valitsemasi pinnat. Käytä sitä laatikoihin, kansiin ja muihin ohutseinäisiin osiin.", + "help.solid.split_body": "Jakaa kappaleen erillisiksi kappaleiksi tasolla, toisen kappaleen pinnalla tai luonnosviivalla. Käytä sitä tekemään osia, jotka tulostetaan, koneistetaan tai väritetään erikseen.", + "help.solid.split_face": "Jakaa valitut pinnat tasoa, toisen kappaleen pintaa tai luonnoskäyriä pitkin leikkaamatta kappaletta. Käytä sitä antamaan pinnan osalle oma päästö, väri tai siirtymä.", + "help.solid.sweep": "Vetää luonnosprofiilia polkua pitkin kappaleeksi. Käytä sitä putkiin, kiskoihin, kahvoihin ja muihin vakiopoikkileikkauksisiin osiin, jotka seuraavat käyrää.", + "help.third_party": "Kolmannen osapuolen lisenssit", + "help.timeline.compute_all": "Rakentaa jokaisen aikajanan piirteen uudelleen alusta alkaen. Käytä sitä mallin päivittämiseen ja nähdäksesi, mitkä piirteet epäonnistuvat.", + "help.timeline.delete": "Poistaa valitut piirteet aikajanalta; luonnoksen poistaminen poistaa myös siitä rakennetut piirteet. Käytä sitä poistamaan vaihe, jota ei enää tarvita.", + "help.timeline.play": "Toista jäljellä olevat piirteet yksi kerrallaan puolen sekunnin tauolla kunkin uudelleenrakennuksen jälkeen. Pysäytä suorittamalla komento uudelleen tai painamalla Escape; Siirry loppuun hyppää heti loppuun.", + "history.sketch_under_constrained": "Alimääritelty ({count} vapausastetta)", + "history.sketch_under_constrained_one": "Alimääritelty ({count} vapausaste)", + "hole_presets.confirm_delete": "Vahvista poisto", + "hole_presets.delete": "Poista", + "hole_presets.delete_prompt": "Poistetaanko tämä henkilökohtainen esiasetus? Olemassa olevat reiät eivät muutu.", + "hole_presets.full": "Henkilökohtaisessa kirjastossa on jo 64 esiasetusta.", + "hole_presets.invalid_name": "Kirjoita lyhyt nimi ilman rivinvaihtoja tai ohjausmerkkejä.", + "hole_presets.invalid_parameters": "Täydennä reiän parametrit kelvollisilla, kohtuullisen pituisilla lausekkeilla.", + "hole_presets.missing": "Valittu esiasetus ei ole enää käytettävissä.", + "hole_presets.name": "Esiasetuksen nimi", + "hole_presets.name_exists": "Tämänniminen esiasetus on jo olemassa. Käytä eri nimeä.", + "hole_presets.name_hint": "Yksilöllinen nimi", + "hole_presets.overwrite": "Tallenna muutokset valittuun esiasetukseen", + "hole_presets.preset": "Esiasetus", + "hole_presets.recall": "Palauta", + "hole_presets.rename": "Nimeä uudelleen", + "hole_presets.save": "Tallenna uutena", + "hole_presets.scope": "Tallennettu profiiliisi kaikkia projekteja varten.", + "hole_presets.select": "Valitse esiasetus", + "hole_presets.title": "Henkilökohtaiset esiasetukset", + "hole_presets.unsupported_options": "Tämä esiasetus käyttää kierreasetuksia, joita tämä versio ei pysty palauttamaan.", + "hole_presets.unsupported_overwrite": "Tämä esiasetus sisältää asetuksia, joita ei voi korvata turvallisesti.", + "home.back": "Takaisin: {name}", + "home.crashes": "Kaatumisraportit", + "home.design_workspace": "Suunnittelu", + "home.documentation": "Dokumentaatio", + "home.export": "Vie", + "home.export_hint": "Valitse muoto nykyiselle mallille.", + "home.export_json_hint": "Muokattava malli ja komentohistoria", + "home.export_mesh_hint": "Verkko tulostukseen tai jakamiseen", + "home.export_step_hint": "Tarkka kappalegeometria", + "home.import": "Tuo…", + "home.import_hint": "STEP, IGES tai verkko uuteen malliin", + "home.in_this_browser": "tässä selaimessa", + "home.missing": "puuttuu", + "home.month_01": "tammi", + "home.month_02": "helmi", + "home.month_03": "maalis", + "home.month_04": "huhti", + "home.month_05": "touko", + "home.month_06": "kesä", + "home.month_07": "heinä", + "home.month_08": "elo", + "home.month_09": "syys", + "home.month_10": "loka", + "home.month_11": "marras", + "home.month_12": "joulu", + "home.new_inches": "Uusi malli, tuumat", + "home.new_inches_hint": "Tuumat · avautuu Suunnittelu-työtilaan", + "home.new_mm": "Uusi malli", + "home.new_mm_hint": "Millimetrit · avautuu Suunnittelu-työtilaan", + "home.open": "Avaa…", + "home.open_hint": ".sc-malli ({shortcut})", + "home.open_now": "Avaa nyt", + "home.opened_days_ago": "{count} päivää sitten", + "home.opened_hours_ago": "{count} h sitten", + "home.opened_just_now": "Juuri nyt", + "home.opened_minutes_ago": "{count} min sitten", + "home.opening": "avataan…", + "home.properties": "Mallin ominaisuudet", + "home.recent": "Viimeisimmät", + "home.recent_empty": "Avaamasi tai tallentamasi mallit näkyvät tässä.", + "home.recovery_count": "Palautettavia malleja: {count}", + "home.recovery_empty": "Ei palautettavia malleja.", + "home.recovery_hint": "SolidCraft sulkeutui ennen kuin nämä mallit tallennettiin.", + "home.report_issue": "Ilmoita ongelmasta", + "home.samples": "Esimerkit", + "home.settings": "Asetukset", + "home.short_date": "{day}. {month}", + "home.shortcuts": "Pikanäppäimet", + "home.start_hint": "Luo malli, avaa viimeaikainen tiedosto tai tutustu esimerkkiin.", + "home.whats_new": "Uutta", + "home.yesterday": "Eilen", + "install.button_tip": "Asenna SolidCraft sovellukseksi: se avautuu omaan ikkunaansa ja toimii ilman verkkoyhteyttä", + "install.dock_1": "Valitse Arkisto › Lisää Dockiin.", + "install.dock_2": "Napsauta Lisää.", + "install.dock_after": "Sen jälkeen SolidCraft avautuu Dockista omaan ikkunaansa ja toimii ilman verkkoyhteyttä.", + "install.done": "Valmis", + "install.home_screen_1": "Napauta Jaa (… -valikossa, jos se ei ole työkalurivillä).", + "install.home_screen_2": "Valitse Lisää Koti-valikkoon.", + "install.home_screen_3": "Napauta Lisää.", + "install.home_screen_after": "Sen jälkeen SolidCraft avautuu kuvakkeestaan Koti-valikossa ilman Safarin työkalurivejä ja toimii ilman verkkoyhteyttä.", + "install.menu": "Asenna sovellus…", + "install.steps_intro": "Safari asentaa verkkosovellukset omista valikoistaan:", + "install.title": "Asenna SolidCraft", + "issue.body_label": "Raportin teksti", + "issue.note_changed_while_waiting": "Asetukset tai malli muuttuivat esikatselua odotettaessa. Ota kuvakaappaus uudelleen, jotta tekniset tiedot päivittyvät nykyistä näkymää vastaaviksi.", + "issue.note_details_kept": "Kuvakaappaus otettu paikallisesti. Muokkaamasi tekniset tiedot säilytettiin: tarkista, että ne vastaavat yhä nykyistä näkymää.", + "issue.note_details_updated": "Tekniset tiedot päivitettiin vastaamaan nykyistä näkymää. Kuvakaappaus otettu paikallisesti. Tarkista se ennen kuin liität sen GitHubissa.", + "issue.note_image_copied": "Liitä kuva GitHub-luonnokseen tai tallenna PNG, jos kuvan kopiointi ei ole käytettävissä.", + "issue.note_opened": "Tarkista GitHub-luonnos ja liitä kuvakaappaus. GitHubin Submit new issue -painike julkaisee sen.", + "issue.note_opened_copied": "Raportti kopioitu. Liitä se GitHub-luonnokseen, liitä kuvakaappaus ja napsauta sitten GitHubissa Submit new issue.", + "issue.note_removed": "Kuvakaappaus poistettiin tästä luonnoksesta.", + "issue.note_report_copied": "Raportti kopioitu. Liitä se tarvittaessa GitHub-luonnokseen.", + "issue.note_save_failed": "Kuvakaappausta ei voitu tallentaa: {error}", + "issue.note_saved": "Kuvakaappaus tallennettu. Liitä PNG GitHub-luonnokseen.", + "issue.save_screenshot": "Tallenna kuvakaappaus…", + "joint.from_bodies_hint": "Jokainen valittu kappale siirtyy omaan uuteen komponenttiinsa ja pysyy paikallaan.", + "joint.one_body": "Molemmat valinnat ovat kappaleessa {body}. Valitse toinen kohta toisesta osasta.", + "joint.one_component": "Nämä osat ovat saman komponentin kappaleita: {bodies}. Nivel liikuttaa komponenttia toista vasten, joten jokainen osa tarvitsee oman komponentin.", + "joint.rigid_group_one_component": "Nämä {count} kappaletta ovat kaikki samassa komponentissa (tai itse mallissa): käytä ensin komentoa {command}.", + "joint.rigid_group_select_components": "Valitse kaksi tai useampia komponentteja.", + "joint_move.blocked_hint": "Tarkista nivelen tyyppi (kuten Jäykkä), maadoitus, nivelen rajat ja parametriohjatut arvot.", + "joint_move.blocked_title": "Tätä osaa ei voi siirtää", + "joint_move.coarse": "Kiertoaskel akselin lähellä (°)", + "joint_move.distance": "Etäisyys alusta", + "joint_move.done": "Valmis", + "joint_move.fine": "Kiertoaskel kaukana akselista (°)", + "joint_move.fixed": "Tällä osalla ei ole liikkuvaa nivelakselia.", + "joint_move.help": "Siirrä – vedä nuolta tai rengasta. Esc peruu nykyisen vedon.", + "joint_move.legacy_hint": "Poista käytöstä, jos haluat käyttää edellistä Siirrä-paneelia.", + "joint_move.precision": "Vedä katkoviivaohjaimen ulkopuolelle tarkkaa kiertoa varten.", + "joint_move.rotation": "Kierto alusta", + "joint_move.select": "Valitse pinta tai yksi kokoonpanon osa ja valitse sitten Siirrä.", + "joint_move.settings": "Kokoonpanon siirto", + "joint_move.step": "Askel", + "joint_move.use_handles": "Käytä nivelliikkeen kahvoja kokoonpanon siirrossa", + "keymap.command_id": "Komennon tunnus", + "keymap.empty": "Ei vastaavia komentoja", + "keymap.use_anyway": "Käytä {key} tässä silti", + "label.move_to_left_side": "Siirrä vasemmalle puolelle", + "label.move_to_right_side": "Siirrä oikealle puolelle", + "manage.appearance_hint": "Valitse valinnalle ulkoasu tai vedä värimalli kappaleen tai pinnan päälle.", + "manage.assign_hint": "Valitse kappaleet ja valitse niille muovisuunnittelusääntö.", + "manage.config_hint": "Muokkaa konfiguraation rivejä, sarakkeita ja sääntöjä ja aktivoi tai esikatsele sitten riviä.", + "manage.empty": "Ei vastaavia rivejä.", + "manage.material_hint": "Valitse kappaleet ja valitse niiden fyysinen materiaali.", + "manage.parameters_hint": "Muokkaa lausekkeita ja yksiköitä. Muutokset tulevat voimaan Enterillä tai kentästä poistuttaessa.", + "manage.rules_hint": "Valitse muokattava sääntö tai luo uusi sääntö. Tallenna sääntö vahvistamalla.", + "material.editor.add_property": "Lisää ominaisuus", + "material.editor.add_source": "Lisää lähde", + "material.editor.assigned_density": "Määritetty tiheys", + "material.editor.axes": "Materiaaliakselit", + "material.editor.axes_numeric": "1 / 2 / 3", + "material.editor.axes_wood": "L / R / T", + "material.editor.copy_name": "Kopio: {name}", + "material.editor.density_range": "Määritetyn tiheyden on oltava yli 0 ja enintään 1000 g/cm³.", + "material.editor.designation": "Merkintä", + "material.editor.duplicate": "Monista", + "material.editor.engineering_card": "Teknisten ominaisuuksien kortti", + "material.editor.family": "Materiaaliryhmä", + "material.editor.glass": "Lasi", + "material.editor.illustrative": "Havainnollistava", + "material.editor.invalid_property": "Ominaisuustyyppiä ei tueta.", + "material.editor.isotropic": "Isotrooppinen", + "material.editor.measurement": "Mittaus", + "material.editor.metal": "Metalli", + "material.editor.moisture": "Kosteus, kuiva-aineesta", + "material.editor.name_required": "Kirjoita nimi, jossa on 1–128 merkkiä.", + "material.editor.no_draft": "Materiaaliluonnosta ei ole auki.", + "material.editor.number_required": "Kirjoita äärellinen numeroarvo.", + "material.editor.orthotropic": "Ortotrooppinen", + "material.editor.other": "Muu", + "material.editor.polymer": "Polymeeri", + "material.editor.preserved_appearance": "Säilytetty ulkoasu", + "material.editor.public_reference": "Julkinen lähde", + "material.editor.reference": "Viite", + "material.editor.save_or_discard": "Tallenna tai hylkää materiaaliluonnos ennen materiaalin määrittämistä.", + "material.editor.saved": "Materiaali tallennettu. Kappaleiden määritykset eivät muuttuneet.", + "material.editor.scope": "Tallennus tallentaa tämän materiaalin määrittämättä sitä kappaleille. Tyhjät ominaisuudet ja olosuhteet jäävät tuntemattomiksi. Kortin tiheys ei muuta määritettyä tiheyttä tai massaa.", + "material.editor.source_id": "Lähteen tunnus", + "material.editor.source_kind": "Lähteen tyyppi", + "material.editor.source_title": "Lähteen nimi", + "material.editor.sources": "Lähteet", + "material.editor.stale": "Tämä materiaali muuttui luonnoksen ollessa auki. Hylkää luonnos ja avaa materiaali uudelleen ennen tallennusta.", + "material.editor.state": "Materiaalin tila", + "material.editor.strain_rate": "Venymänopeus", + "material.editor.symmetry": "Elastinen symmetria", + "material.editor.temperature": "Lämpötila", + "material.editor.unknown_card": "Tekniset ominaisuudet ovat tuntemattomia. Tallennus voi säilyttää vain määritetyn tiheyden ja nykyisen ulkoasun.", + "material.editor.user_supplied": "Käyttäjän antama", + "material.editor.wood": "Puu", + "material.selection.choose": "Valitse materiaali", + "material.selection.mixed": "Eri materiaaleja", + "measure.at": "Kohdassa", + "measure.item": "Kohde {number}", + "measure.quick_copied": "Mittaus kopioitu", + "measure.quick_copy": "Kopioi mittaus napsauttamalla", + "measure.quick_edges": "{count} särmää", + "measure.quick_faces": "{count} pintaa", + "messages.ago_h": "{n} h sitten", + "messages.ago_min": "{n} min sitten", + "messages.ago_s": "{n} s sitten", + "messages.chip_hint": "Näytä kaikki tämän istunnon viestit napsauttamalla", + "messages.clear": "Tyhjennä", + "messages.copy_all": "Kopioi kaikki", + "messages.menu": "Viestit…", + "messages.none": "Ei vielä viestejä.", + "messages.title": "Viestit", + "motion.checked_to": "Tarkistettu vain vaiheeseen {step} asti: tarkistuksen laskentabudjetti loppui", + "motion.collision": "{a} · {b}: vaiheet {steps}", + "motion.delete_key": "Poista avainkehys", + "motion.duration_hint": "sekuntia (tyhjä: vaihekohtainen)", + "motion.key_here": "Avainkehys tähän", + "motion.key_here_hint": "Lisää tähän vaiheeseen avainkehys nivelen nykyisellä arvolla", + "motion.no_collisions": "Ei törmäyksiä tutkimuksen aikana", + "motion.plot_quantity": "Suure", + "motion.step_back": "Askel taaksepäin", + "motion.step_forward": "Askel eteenpäin", + "motion.to_end": "Loppuun", + "motion.to_start": "Alkuun", + "motion.track_name": "Raita {number}", + "nav.alias.help": "Shift+Alt+vasen veto kiertää, Shift+Alt+keski panoroi, Shift+Alt+oikea zoomaa; keskiveto myös panoroi ja oikea veto kiertää", + "nav.blender.help": "Keskiveto kiertää, Shift+keski panoroi, Ctrl+keski zoomaa; oikea veto ei liikuta kameraa", + "nav.free_orbit.help": "Hiiri ja 3D-hiiri kiertävät molempien napojen yli ja säilyttävät kallistuksen. Shift-akselitartunta on poissa käytöstä.", + "nav.free_orbit.label": "Vapaa kierto", + "nav.free_orbit.snap_help": "Hiiri ja 3D-hiiri kiertävät molempien napojen yli ja säilyttävät kallistuksen. Pidä Shift painettuna kiertovedon aikana, niin katselusuunta ja kallistus tarttuvat.", + "nav.free_orbit_upright.help": "Sivuttaiset hiirivedot pyörittävät mallia maailman pystyakselin ympäri; ylös- ja alasvedot kallistavat yläkautta ilman rajaa.", + "nav.free_orbit_upright.label": "Kierrä pystyakselin ympäri", + "nav.fusion.help": "Keskiveto panoroi, Shift+keski kiertää, oikea veto kiertää", + "nav.hfss.help": "Alt+vasen veto panoroi, Shift+Alt+vasen zoomaa, Ctrl+vasen veto tyhjässä kohdassa kiertää", + "nav.inventor.help": "Keskiveto panoroi, Shift+keski kiertää, Ctrl+keski zoomaa, oikea veto kiertää", + "nav.maya.help": "Alt+vasen veto kiertää, Alt+keski panoroi, Alt+oikea zoomaa; keskiveto myös panoroi", + "nav.openscad.help": "Vasen veto tyhjässä kohdassa kiertää, Shift+vasen veto valitsee laatikolla, luonnosgeometrian vetäminen muokkaa; oikea tai keskiveto panoroi, Shift+oikea tai Shift+keskiveto zoomaa", + "nav.perspective.sketch": "Luonnoksen muokkaus käyttää ortografista näkymää. Viimeistele luonnos vaihtaaksesi perspektiiviin.", + "nav.right_drag_orbits.help": "Pois: oikea veto ei liikuta kameraa, ja oikea painike vain avaa pikavalikon. SolidWorks-hiirikartoituksen valinta poistaa tämän käytöstä.", + "nav.right_drag_orbits.label": "Oikea veto kiertää", + "nav.solidworks.help": "Keskiveto kiertää, Ctrl+keski panoroi, Shift+keski zoomaa, oikea veto kiertää", + "nav.tinkercad.help": "Oikea veto kiertää, keski- tai Shift+oikea veto panoroi", + "nav.touchpad.help": "Panoroi pitämällä Shift painettuna ja liikuttamalla osoitinta; kierrä pitämällä Alt/Option painettuna ja liikuttamalla sitä. Hiiren painiketta ei tarvita. Pystyvieritys zoomaa.", + "nav.touchpad.label": "Kosketuslevy", + "nav.touchpad.orbit": "Kierto (pidä Alt/Option painettuna ja liikuta; ei hiiren painiketta)", + "nav.touchpad.pan": "Panorointi (pidä Shift painettuna ja liikuta; ei hiiren painiketta)", + "nav.touchpad.scroll": "Kosketuslevy zoomaa pystyvierityksellä. Kahden sormen panorointiasetuksesi säilyy muita esiasetuksia varten.", + "nav.touchpad.zoom": "Zoomaus (pystyvieritys tai nipistys)", + "navbar.agent_chat": "Avustajan keskustelupaneeli", + "navbar.agent_cursor": "Näytä agentin kohdistin", + "navbar.ambient_occlusion": "Ympäristön peittovarjostus", + "navbar.assistant": "Avustaja", + "navbar.custom.add_slot": "Lisää {slot}", + "navbar.custom.always": "Aina näkyvissä", + "navbar.custom.available": "{available} · vedä palkkiin tai paina Lisää", + "navbar.custom.buttons": "Painikkeet", + "navbar.custom.compact": "Tiivis", + "navbar.custom.count": "{count}/16 paikkaa", + "navbar.custom.customise": "Mukauta navigointipalkkia…", + "navbar.custom.design": "Suunnittelu", + "navbar.custom.duplicate": "Tämä paikka on jo palkissa.", + "navbar.custom.edge": "Reuna", + "navbar.custom.edge_bottom": "Ala", + "navbar.custom.edge_top": "Ylä", + "navbar.custom.fade": "Häivytä", + "navbar.custom.full": "Poista paikka lisätäksesi toisen.", + "navbar.custom.hidden_design": "Piilotettu Suunnittelussa", + "navbar.custom.hidden_render": "Piilotettu Renderöinnissä", + "navbar.custom.hide": "Piilota navigointipalkki", + "navbar.custom.hide_idle": "Piilota, kunnes osoitin viedään päälle", + "navbar.custom.icon_only": "Vain kuvake", + "navbar.custom.icon_value": "Kuvake ja arvo", + "navbar.custom.icons": "Kuvakkeet", + "navbar.custom.idle": "Käyttämättömänä", + "navbar.custom.intro": "Valitse 3D-näkymän alareunan palkin säätimet ja niiden järjestys. Siirrä paikkaa vetämällä sitä tai poista se vetämällä se pois palkista.", + "navbar.custom.labels": "Kuvakkeet ja nimiöt", + "navbar.custom.left": "Siirrä vasemmalle", + "navbar.custom.position": "Sijainti", + "navbar.custom.position_centre": "Keskellä", + "navbar.custom.position_left": "Vasemmalla", + "navbar.custom.position_right": "Oikealla", + "navbar.custom.preview_as": "Esikatsele tilassa", + "navbar.custom.regular": "Normaali", + "navbar.custom.remove": "Poista", + "navbar.custom.remove_bar": "Poista palkista", + "navbar.custom.render": "Renderöinti", + "navbar.custom.reset": "Palauta navigointipalkki", + "navbar.custom.reset_default": "Palauta oletus", + "navbar.custom.right": "Siirrä oikealle", + "navbar.custom.select": "Paikkaa ei ole valittu. Valitse palkista paikka siirtääksesi tai poistaaksesi sen tai muuttaaksesi sen ulkoasua.", + "navbar.custom.separators_full": "Palkissa on jo neljä erotinta.", + "navbar.custom.show": "Näytä navigointipalkki", + "navbar.custom.shows": "Näyttää", + "navbar.custom.size": "Koko", + "navbar.custom.stale": "Palkki muuttui. Valitse paikka uudelleen.", + "navbar.custom.title": "Navigointipalkki", + "navbar.custom.unknown": "Tämä paikka ei ole käytettävissä.", + "navbar.description.ambient_occlusion": "Tummentaa uria ja nurkkia syvyysvaikutelman lisäämiseksi.", + "navbar.description.display": "Sisältää näkymäasetukset: grafiikan esiasetus, ruudukko, maavarjo, ympäristön peittovarjostus ja projektio.", + "navbar.description.fit": "Rajaa näkymään valinnan tai koko mallin, jos mitään ei ole valittu.", + "navbar.description.grid": "Näyttää tai piilottaa maaruudukon.", + "navbar.description.ground_shadow": "Näyttää tai piilottaa mallin alla olevan varjon.", + "navbar.description.home": "Palaa koko mallin isometriseen näkymään.", + "navbar.description.look_at": "Kääntää näkymän kohti valittua pintaa, tasoa tai avointa luonnosta.", + "navbar.description.orbit": "Kiertää näkymää mallin ympäri.", + "navbar.description.pan": "Liu'uttaa näkymää sivuttain, ylös tai alas.", + "navbar.description.projection": "Vaihtaa ortografisen ja perspektiivinäkymän välillä.", + "navbar.description.separator": "Ohut viiva, joka ryhmittelee vierekkäisiä paikkoja.", + "navbar.description.spin": "Pitää näkymän pyörimässä, kunnes napsautat tai painat näppäintä.", + "navbar.description.visual_style": "Vaihtaa mallin piirtotapaa: varjostettu, rautalanka tai piilosärmin.", + "navbar.description.zoom": "Siirtää näkymää lähemmäs mallia tai kauemmas siitä.", + "navbar.display": "Näyttö", + "navbar.fit": "Sovita", + "navbar.graphics": "Grafiikka", + "navbar.grid": "Ruudukko", + "navbar.ground_shadow": "Maavarjo", + "navbar.home": "Aloitus", + "navbar.later": "Myöhemmin", + "navbar.look_at": "Katso kohti", + "navbar.more": "Lisää", + "navbar.orbit": "Kierrä", + "navbar.orthographic": "Ortografinen", + "navbar.pan": "Panoroi", + "navbar.perspective": "Perspektiivi", + "navbar.preset": "Esiasetus", + "navbar.projection": "Projektio", + "navbar.projection_heading": "Projektio", + "navbar.separator": "Erotin", + "navbar.show": "Näytä", + "navbar.spin": "Pyöritä", + "navbar.style.shaded": "Varjostettu", + "navbar.style.shaded_with_edges": "Varjostettu särmineen", + "navbar.style.shaded_with_hidden_edges": "Varjostettu, piilosärmät näkyvissä", + "navbar.style.wireframe": "Rautalanka", + "navbar.translucent": "Läpikuultava", + "navbar.value_tip": "{name} · {value}", + "navbar.visual_style": "Visuaalinen tyyli", + "navbar.zoom": "Zoomaa", + "needs two joints": "tarvitsee kaksi niveltä", + "network.allow_once": "Salli kerran", + "network.always_allow": "Salli tämä palvelin aina", + "network.prompt_detail": "Tämä malli linkittää tiedostoon verkkosijainnissa {host}. Sen avaaminen muodostaa yhteyden kyseiseen tietokoneeseen ja voi lähettää sille Windows-kirjautumistietosi. Salli vain luotettavat palvelimet.", + "network.prompt_title": "Verkkosijainti", + "new configuration": "uusi konfiguraatio", + "new parameter": "uusi parametri", + "no hole": "ei reikää", + "no interference": "ei päällekkäisyyttä", + "no joint that moves yet": "ei vielä liikkuvaa niveltä", + "no motion study yet": "ei vielä liiketutkimusta", + "no picture": "ei kuvaa", + "no such joint": "niveltä ei ole", + "none": "ei mitään", + "not a move": "ei siirto", + "not a number": "ei luku", + "open a sketch first": "avaa ensin luonnos", + "parts.automatic_name": "automaattinen", + "parts.dimension.across_flats": "Avainväli", + "parts.dimension.bore": "Reiän halkaisija", + "parts.dimension.d": "Nimellishalkaisija", + "parts.dimension.head_diameter": "Kannan halkaisija", + "parts.dimension.head_height": "Kannan korkeus", + "parts.dimension.height": "Korkeus", + "parts.dimension.inner": "Sisähalkaisija", + "parts.dimension.outer": "Ulkohalkaisija", + "parts.dimension.pitch": "Nousu", + "parts.dimension.socket": "Kolon koko", + "parts.dimension.socket_depth": "Kolon syvyys", + "parts.dimension.thickness": "Paksuus", + "parts.dimension.width": "Leveys", + "parts.family.bearing": "Urakuulalaakeri (ulkomitat)", + "parts.family.button_head_cap_screw": "Kupukantainen kuusiokoloruuvi", + "parts.family.dowel_pin": "Lieriötappi (ohjaintappi)", + "parts.family.hex_bolt": "Kuusiokantapultti / -ruuvi", + "parts.family.hex_nut": "Kuusiomutteri", + "parts.family.set_screw": "Kuusiokolostoppiruuvi, tasapää", + "parts.family.socket_head_cap_screw": "Kuusiokoloruuvi", + "parts.family.washer": "Tavallinen aluslevy", + "parts.holes": "Reiät (valinnainen)", + "parts.placement_hint": "valitse yksi tai useampi reikä, johon kuhunkin asetetaan osa, tai OK sijoittaa sen origoon", + "pattern.alignment_2": "Kohdistus 2", + "pattern.alignment_hint": "Määriin sisältyy alkuperäinen. Keskitä tasapainottaa kopiot; Käännä valitsee ylimääräisen puolen parillisilla määrillä.", + "pattern.direction_1": "Suunta 1", + "pattern.direction_2": "Suunta 2", + "pattern.flip_direction": "Käännä suunta", + "perf.click_for_details": "Napsauta nähdäksesi tiedot: ruutuaikakaavio, histogrammi ja mitä GPU piirsi", + "perf.draw_calls": "Piirtokutsut", + "perf.line_segments": "Viivasegmentit", + "perf.low_1": "1 % alin", + "perf.renderer": "Renderöijä", + "perf.scene_build": "Näkymän rakennus", + "perf.triangles": "Kolmiot", + "perf.ui_time": "Käyttöliittymän aika", + "perf.viewport": "Näkymäikkuna", + "perf.worst": "huonoin", + "pick a body or some of its faces": "valitse kappale tai osa sen pinnoista", + "pick a hole to seat it, or OK places it at the origin": "valitse reikä, johon se asetetaan, tai OK sijoittaa sen origoon", + "pick a planar face": "valitse tasomainen pinta", + "pick a plane or a face as the tool": "valitse työkaluksi taso tai pinta", + "pick a size": "valitse koko", + "pick edges of a body": "valitse kappaleen särmiä", + "pick edges of one body": "valitse yhden kappaleen särmiä", + "pick edges of the holes to remove": "valitse poistettavien reikien särmät", + "pick faces of one body": "valitse yhden kappaleen pintoja", + "pick faces to offset": "valitse siirrettävät pinnat", + "pick lines of one sketch": "valitse yhden luonnoksen viivoja", + "pick one or two vertices, edges or faces": "valitse yksi tai kaksi kärkipistettä, särmää tai pintaa", + "pick one plane or face as the tool, or sketch curves": "valitse työkaluksi yksi taso tai pinta, tai luonnoskäyriä", + "pick sections in two or more sketches": "valitse profiileja kahdesta tai useammasta luonnoksesta", + "pick sketch curves or edges": "valitse luonnoskäyriä tai särmiä", + "pick the face to untrim": "valitse pinta, jonka katkaisu poistetaan", + "pick the path": "valitse polku", + "pick what to keep": "valitse säilytettävä osa", + "pick what to keep of the other surface": "valitse toisesta pinnasta säilytettävä osa", + "prefs.add_item": "Lisää kohde", + "prefs.alt_frees_manipulators": "Alt / Option vapauttaa myös manipulaattorin nuolet", + "prefs.anti_aliasing_amd_dx12": "Reunanpehmennys käyttää tasoa 1×, koska käynnistystarkistus ei piirtänyt mitään moninäytteistyksellä ja onnistui tasolla 1×. Voit valita toisen asetuksen yllä; käynnistä uudelleen ottaaksesi sen käyttöön, jos tarkistus onnistuu.", + "prefs.anti_aliasing_note": "Tasoittaa rosoiset reunat.", + "prefs.applies_on_restart": "Tulee voimaan uudelleenkäynnistyksessä", + "prefs.assistant": "Avustaja", + "prefs.auto_operation": "Valitse toiminto automaattisesti", + "prefs.auto_operation_note": "Piirreikkunat valitsevat Yhdistä, Uusi kappale tai Leikkaa sen mukaan, mihin uusi materiaali koskee, kunnes valitset jonkin. Pois: ne säilyttävät toiminnon, jolla ne avautuvat.", + "prefs.axes": "Akselit", + "prefs.axis": "Akseli", + "prefs.bodies_folders": "Kappalekansiot", + "prefs.bodies_folders_always": "Aina", + "prefs.bodies_folders_never": "Ei koskaan", + "prefs.bodies_folders_several": "Vain useita kappaleita", + "prefs.bottom": "Ala", + "prefs.bottom_centre": "Alas keskelle", + "prefs.bottom_right": "Alas oikealle", + "prefs.browser": "Selain", + "prefs.browser_eye_left": "Silmä taittonuolen vieressä", + "prefs.browser_eye_left_note": "Selaimen rivien näytä/piilota-silmä on taittonuolen vieressä kuten ennenkin, eikä sarakkeessa rivien oikeassa reunassa.", + "prefs.browser_reveal_on_hover": "Näytä osoitettaessa", + "prefs.browser_show_note": "Näytetty pitää selaimen näytöllä. Näytä osoitettaessa pitää sen piilossa, kunnes osoitin tulee sen paikalle tai sen vasemman reunan ohuen kielekkeen päälle, ja se pysyy auki, kun osoitin on sen sisällä tai sen valikko on auki.", + "prefs.browser_shown": "Näytetty", + "prefs.built_in": "Sisäänrakennettu", + "prefs.button_left": "Vasen veto", + "prefs.button_middle": "Keskiveto", + "prefs.button_right": "Oikea veto", + "prefs.choose_font": "Valitse fonttitiedosto…", + "prefs.click": "Napsautus", + "prefs.contact_shading": "Pehmeä kosketusvarjostus", + "prefs.contact_shading_note": "Pehmeä varjostus kohdissa, joissa osat ovat lähellä toisiaan.", + "prefs.context_menu": "Pikavalikko", + "prefs.context_menu_ring": "Pikavalikko · pidä painettuna rengasvalikkoa varten", + "prefs.cpu": "CPU (ohjelmisto)", + "prefs.crash_reports": "Avaa kaatumisraportit…", + "prefs.crash_reports_note": "Raportit säilytetään tällä tietokoneella yhdessä mallin ja ongelman toistavien komentojen kanssa.", + "prefs.ctrl_cmd": "Ctrl / Cmd", + "prefs.curved_surfaces": "Kaarevat pinnat", + "prefs.curves": "Kaarevat pinnat", + "prefs.dead_zone_note": "Viivoitettu kaista on kuollut alue: sen sisäinen liike ohitetaan.", + "prefs.default": "oletus", + "prefs.design": "Mallin oletukset", + "prefs.design_note": "Millä uudet mallit alkavat. Avoimet mallit säilyttävät omansa.", + "prefs.devices": "3D-hiiri", + "prefs.dialog_buttons": "Valintaikkunan painikkeet", + "prefs.dialogs": "Valintaikkunat", + "prefs.dialogs_edge": "Näkymän reunassa", + "prefs.dialogs_left_column": "Vasemmassa sarakkeessa", + "prefs.dialogs_note": "Mihin komentojen valintaikkunat avautuvat: telakoituna näkymän reunaan tai vasempaan sarakkeeseen selaimen ja historian tilalle, jotka palaavat ennalleen, kun valintaikkuna suljetaan.", + "prefs.display": "Näyttö", + "prefs.edges": "Särmät ja varjostus", + "prefs.extend": "Jatka", + "prefs.extra_navigation": "Ctrl+vasen veto tyhjässä kohdassa kiertää; pidä välilyöntiä painettuna panoroidaksesi.", + "prefs.follows_os": "Seuraa käyttöjärjestelmää", + "prefs.font": "Fontti", + "prefs.font_fallback": "Merkit, joita valitussa fontissa ei ole, näytetään sisäänrakennetulla fontilla, joten mikään nimiö ei näytä tyhjää ruutua.", + "prefs.font_sample": "Vikkelä ruskea kettu · Ø12.50 mm · 45.0° · 0123456789", + "prefs.font_unreadable": "Valittua fonttia ei voitu lukea.", + "prefs.general": "Yleiset", + "prefs.general_note": "Kieli, tallennus ja päivitykset.", + "prefs.gesture_orbit": "Kierrä", + "prefs.gesture_pan": "Panoroi", + "prefs.gesture_zoom": "Zoomaa", + "prefs.graphics": "Grafiikka", + "prefs.graphics_card": "Näytönohjain", + "prefs.graphics_card_auto": "Automaattinen", + "prefs.graphics_card_missing": "Tallennettu näytönohjain ei ole käytettävissä; käytetään automaattista.", + "prefs.graphics_card_restart": "Tulee voimaan, kun SolidCraft käynnistetään uudelleen. WGPU_ADAPTER_NAME ohittaa tämän valinnan.", + "prefs.graphics_note": "Vaihda nopeutta sulavuuteen. Kunkin valinnan vaikutus näkyy näytteessä.", + "prefs.graphics_presets_note": "Esiasetukset asettavat alla olevat valinnat yhdessä.", + "prefs.hide_constraint_glyphs_zoomed_out": "Piilota rajoitesymbolit loitonnettaessa", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Rajoitesymbolit poistuvat näkymästä, kun luonnos on loitonnettu niin kauas, että ne sotkisivat sen. Pois: ne pysyvät kiinteän kokoisina näytöllä kaikilla zoomaustasoilla.", + "prefs.history": "Historia", + "prefs.history_origin_bodies": "Näytä origo ja kappaleet ylimpänä", + "prefs.history_origin_bodies_note": "Vasemmalla tai selaimen alla oleva historia alkaa aktiivisen komponentin Kappaleet-kansiolla ja origolla, joiden silmät ja valikot ovat kuten selaimessa. Alareunan historia ei muutu.", + "prefs.history_play_button": "Toista historia -painike historian ohjaimissa", + "prefs.history_play_button_note": "Lisää Toista historia -painikkeen Siirry askel taaksepäin- ja Siirry askel eteenpäin -painikkeiden väliin historian ohjaimissa. Voit myös näyttää tai piilottaa sen napsauttamalla ohjaimia hiiren oikealla painikkeella.", + "prefs.history_sketches": "Luonnokset", + "prefs.history_sketches_in_order": "Järjestyksessä", + "prefs.history_sketches_note": "Kun Luonnokset-asetuksena on Piirteensä alla, vasemman reunan historia näyttää kunkin luonnoksen tason sisempänä ensimmäisen siitä tehdyn piirteen alla aina, kun se on tarpeeksi leveä nimille. Alareunan aikajana säilyttää aina luontijärjestyksen.", + "prefs.history_sketches_under_feature": "Piirteensä alla", + "prefs.hold_stop_snapping": "Pidä painettuna tartunnan estämiseksi", + "prefs.icons_only": "Vain kuvakkeet", + "prefs.imported_file": "Tuotu tiedosto", + "prefs.in_browser": "Selaimessa", + "prefs.input": "Syöttö", + "prefs.interface": "Käyttöliittymä", + "prefs.interface_language": "Käyttöliittymän kieli", + "prefs.interface_note": "Miltä SolidCraft näyttää ja missä sen osat sijaitsevat.", + "prefs.interval_minutes": "{n} min", + "prefs.labels": "Nimiöt", + "prefs.layout": "Asettelu", + "prefs.layout_note": "Tiivis telakoi paneelit ikkunan reunoihin. Selaimen läpikuultavuus on käytettävissä Kelluva-asettelussa. Vaihda avustajan puolta Avustaja-sivulla.", + "prefs.left": "Vasen", + "prefs.left_click": "Vasen napsautus", + "prefs.line_drag_draws_one": "Painaminen ja vetäminen piirtää yhden viivan", + "prefs.line_drag_draws_one_note": "Viiva-työkalussa painaminen, vetäminen ja vapauttaminen piirtää yhden viivan painalluskohdasta vapautuskohtaan, ja se päättyy siihen. Napsautukset piirtävät edelleen ketjun. Pois: veto sijoittaa pisteen vapautuskohtaan kuten napsautus.", + "prefs.linked_designs": "Linkitetyt mallit", + "prefs.live_input": "Reaaliaikainen syöttö", + "prefs.live_preview": "Reaaliaikainen esikatselu", + "prefs.logo": "Logo", + "prefs.logo_accent": "Korostusväri", + "prefs.logo_theme": "Teeman värit", + "prefs.max_file_size": "Suurin avattava tiedosto", + "prefs.max_file_size_design": "Mallitiedostot", + "prefs.max_file_size_flag": "Tämä ajokerta käynnistettiin valitsimella --max-file-size {size}, joka on voimassa, kunnes SolidCraft suljetaan. Nämä asetukset ovat voimassa, kun se käynnistetään ilman sitä.", + "prefs.max_file_size_note": "Oletukset: 512 MB ja 256 MB; enintään 16 GB. Tiedoston avaaminen vie muistia useita kertoja sen koon verran. Malli, joka säilyttää suuren tuonnin, voi tarvita suuremman mallitiedostorajan tallentuakseen ja avautuakseen uudelleen.", + "prefs.max_file_size_step": "STEP- ja tuontitiedostot", + "prefs.max_file_size_web": "Verkkosovellus avaa enintään 512 MB:n STEP- ja tuontitiedostot ja enintään 256 MB:n mallit: selaimen välilehti ei pysty käsittelemään suurempia tiedostoja. Työpöytäsovellus voi nostaa näitä rajoja.", + "prefs.megabytes": "MB", + "prefs.menu_grid": "Suuret kuvakkeet", + "prefs.menu_list": "Luettelo", + "prefs.menus": "Valikot", + "prefs.minutes": "minuuttia", + "prefs.minutes_suffix": " min", + "prefs.mouse": "Hiiri ja kosketuslevy", + "prefs.mouse_heading": "Hiiri", + "prefs.mouse_mapping": "Hiiren toiminnot", + "prefs.navigation": "Navigointi", + "prefs.navigation_note": "Miten hiiri, kosketuslevy ja 3D-hiiri liikuttavat näkymää.", + "prefs.network_host_remove": "Poista", + "prefs.network_locations": "Salli verkkosijainnit malleissa", + "prefs.network_locations_note": "Pois: mallin linkitetyt tiedostot toisella tietokoneella (\\\\server\\share) avataan vain tässä luetelluilta palvelimilta tai sen jälkeen, kun sallit palvelimen kysyttäessä.", + "prefs.network_locations_section": "Verkkosijainnit", + "prefs.not_on_gpu": "ei tällä GPU:lla", + "prefs.on": "Päällä", + "prefs.opaque": "Läpinäkymätön", + "prefs.orbit_snap": "Pidä Shift painettuna kierrettäessä tarttuaksesi akseliin.", + "prefs.orientation": "Suunta", + "prefs.origin_plane_names": "Origotasojen nimet", + "prefs.origin_plane_names_note": "Selaimessa, valintaikkunoissa ja luetteloissa näkyvät nimet. {top} on XY, {front} on XZ ja {right} on YZ, Z ylöspäin. Komennot, skriptit ja tallennetut mallit säilyttävät nimet XY, XZ ja YZ.", + "prefs.overall_speed": "Kokonaisnopeus", + "prefs.persistent_ribbon_pressed": "Korosta aktiiviset valintanauhan komennot", + "prefs.persistent_ribbon_pressed_help": "Korosta valintanauhan komennot, kun niiden vaikutukset ovat päällä. Poista käytöstä säilyttääksesi aiemman neutraalin ulkoasun ja komentojen ohjeen.", + "prefs.perspective_fov": "Näkökenttä", + "prefs.placement": "Sijoitus", + "prefs.prefer_free_sketch_points": "Suosi siirrettäviä luonnospisteitä, kun pisteet ovat päällekkäin", + "prefs.preview_choices": "Valintojesi esikatselu", + "prefs.print_face_highlight": "Tulostuspinnan korostus", + "prefs.print_face_highlight_tip": "Kuinka voimakkaasti kappaleen tulostuspinta sävytetään ja ääriviivataan näkymässä. 0 % ei näytä korostusta.", + "prefs.profile_active": "Aktiivinen profiili", + "prefs.profile_applied": "Profiili otettu käyttöön.", + "prefs.profile_changed": "Asetuksia on muutettu profiilin tallentamisen jälkeen. Tallenna säilyttää ne siinä.", + "prefs.profile_deleted": "Profiili poistettu.", + "prefs.profile_export": "Vie…", + "prefs.profile_exported": "Profiili viety.", + "prefs.profile_imported": "Profiili tuotu. Vaihda siihen valitsemalla se kohdassa Aktiivinen profiili.", + "prefs.profile_name_hint": "Profiilin nimi, esim. Kannettava", + "prefs.profile_rename": "Nimeä uudelleen", + "prefs.profile_renamed": "Profiili nimetty uudelleen.", + "prefs.profile_save": "Tallenna", + "prefs.profile_saved": "Nykyiset asetukset tallennettu profiiliin.", + "prefs.profile_skipped": "Profiili otettu käyttöön; {n} asetus(ta) säilytti nykyisen arvonsa (katso loki).", + "prefs.profiles": "Asetusprofiilit", + "prefs.profiles_note": "Pikanäppäimet, työkalurivi, navigointi, rengasvalikko ja käyttöliittymän asetukset yhdessä nimen alla, vaihdettavissa kerralla.", + "prefs.project_options_dialog": "Projisoinnin asetukset -valintaikkuna", + "prefs.project_options_dialog_note": "Päällä: valitse Geometria tai Kappaleet ja projisointilinkki ennen projisointia. Pois: säilytä aiempi suoravalintatyönkulku.", + "prefs.quality_default": "oletus", + "prefs.quality_fastest": "nopein", + "prefs.quality_slowest": "hitain", + "prefs.quality_smoother": "sulavampi", + "prefs.quick_measure": "Pikamittaus näkymän kulmassa", + "prefs.quick_measure_note": "Valinnan koko näkymän oikeassa alakulmassa: särmän pituus tai pyöreän särmän Ø ja R, pinnan ala, pisteen sijainti, kahden kohteen etäisyys ja kulma, useiden särmien tai pintojen yhteismäärä. Napsautus kopioi sen.", + "prefs.render_previous_geometry_budget": "Käytä aiempaa renderöinnin esikatselun geometriabudjettia", + "prefs.render_previous_geometry_budget_help": "Palauta aiempi kiinteä geometriaraja (512 MiB 64-bittisellä työpöydällä, muualla 128 MiB) laitekohtaisen budjetin sijaan.", + "prefs.reports_saved": "{n} tallennettu", + "prefs.ribbon_reveal": "Näytä hakutulokset valintanauhassa", + "prefs.ribbon_reveal_note": "Kun osoitat tulosta Hae komentoja -haussa, näytetään, missä komento on valintanauhassa: sen välilehti, painike tai rivi paneelin pudotusvalikossa. Haun kautta käynnistetty komento vilkkuu siellä, ja pudotusvalikosta käynnistetty käynnissä oleva komento merkitsee paneelinsa.", + "prefs.right_click": "Oikea napsautus", + "prefs.saving": "Automaattinen tallennus ja palautus", + "prefs.scale_note": "Myös Ctrl + / Ctrl − ikkunassa. Painikkeet ovat 28 px tasolla 100 %.", + "prefs.scale_text": "Skaalaus ja teksti", + "prefs.search": "Hae asetuksista", + "prefs.search_empty": "Ei vastaavia asetuksia", + "prefs.select": "Valitse", + "prefs.select_one_item": "Valitse yksi kohde", + "prefs.select_other_hidden": "Valitse piilotettu geometria Valitse muu -toiminnolla", + "prefs.select_other_hidden_help": "Päällä: mukaan piilotetut kappaleet, luonnokset ja peittyvät särmät osoittimen lähellä. Pois: käytä alkuperäistä luetteloa osoittimen alla näkyvästä geometriasta.", + "prefs.selecting": "Valitseminen", + "prefs.shift": "Shift", + "prefs.shift_add_ctrl_toggle": "Shift vain lisää; Ctrl / Cmd vaihtaa", + "prefs.shift_add_note": "Päällä: Shift lisää, Ctrl / Cmd vaihtaa valinnan. Pois (oletus): molemmat vaihtavat.", + "prefs.shortcuts": "Pikanäppäimet", + "prefs.single_surface_analysis": "Käytä yhtä pinta-analyysiä kerrallaan", + "prefs.single_surface_analysis_help": "Kun asetus on pois, pintavarjostus ja jatkuvuus voivat olla päällä yhtä aikaa. Kun tämä on päällä, säilytetään viimeksi käynnistetty tai muutettu analyysi.", + "prefs.sketch_align_points": "Kohdista muihin luonnospisteisiin piirrettäessä", + "prefs.sketch_align_points_note": "Piirrettäessä piste kohdistuu samanaikaisesti vaakasuoraan yhden luonnospisteen ja pystysuoraan toisen kanssa pisteviivaohjaimella, ja napsautus säilyttää molemmat kohdistukset. Pois: vain edellisen pisteen kanssa, yksi akseli kerrallaan.", + "prefs.sketch_conflict_colour": "Ristiriitainen geometria ristiriitavärillä", + "prefs.sketch_conflict_colour_note": "Kun luonnoksen rajoitteet ovat ristiriidassa, myös epäonnistuneen rajoitteen pitämät käyrät ja pisteet piirretään ristiriitavärillä, eikä vain sen symbolia tai mittaa. Pois: ne säilyttävät tavalliset värinsä.", + "prefs.sketch_construction_lines": "Laajennetut apuviivatyökalut", + "prefs.sketch_exit_view": "Kun luonnos päättyy", + "prefs.sketch_exit_view.keep": "Säilytä nykyinen näkymä", + "prefs.sketch_exit_view.previous": "Palaa edelliseen näkymään", + "prefs.sketch_exit_view.unless_rotated": "Palaa, ellei kierretty", + "prefs.sketch_exit_view_note": "Mihin näkymä siirtyy Lopeta luonnos- tai Peruuta-toiminnon tai kaksinkertaisen Esc-painalluksen jälkeen.", + "prefs.sketch_open_look_at": "Käänny luonnosta kohti, kun se avautuu", + "prefs.sketch_open_look_at_note": "Pois: luonnoksen luominen tai muokkaaminen jättää näkymän ennalleen. Katso kohti kääntää näkymän luonnosta kohti, kun haluat.", + "prefs.sketch_recent_snap": "Automaattinen tartunta viimeisimpiin keskipisteisiin ja keskikohtiin", + "prefs.sketch_recent_snap_note": "Piirrettäessä kohdistin tarttuu kohtaan, jossa kahden viimeisimmän ympyrän keskipisteen tai suoran särmän keskikohdan ohjaimet risteävät. Pois: ohjaimet näkyvät vain visuaalisesti eikä kohdistin liiku.", + "prefs.sketch_selection": "Luonnos ja valinta", + "prefs.sketch_selection_heading": "Luonnos ja valinta", + "prefs.sketch_selection_note": "Miten luonnokset tarttuvat ja miten napsautukset valitsevat.", + "prefs.sketch_state_icons": "Luonnosten kuvakkeet tilansa värillä", + "prefs.sketch_state_icons_note": "Historiassa ja selaimessa luonnoksen kuvake saa tilansa värin (alimääritelty, täysin määritelty tai ristiriitainen) ja sen työkaluvihje kertoo tilan. Pois: kaikilla luonnoksilla on sama kuvake.", + "prefs.sketch_tab_always": "Pidä Luonnos-välilehti näkyvissä", + "prefs.sketch_tab_always_note": "Luonnos-välilehti pysyy valintanauhassa heti Malli-välilehden jälkeen, vaikka mikään luonnos ei ole auki. Sen piirtotyökalut aloittavat silloin luonnoksen valitulle tasolle tai pinnalle tai seuraavaksi valitsemallesi tasolle, työkalu valmiina.", + "prefs.sketch_tracking_guides": "Keskipisteiden ja keskikohtien kohdistusohjaimet", + "prefs.sketch_tracking_guides_note": "Osoita ympyrän keskipistettä tai suoran särmän keskikohtaa ja liiku sitten vaaka- tai pystysuunnassa näyttääksesi tilapäisen pisteviivaohjaimen.", + "prefs.sketching": "Luonnostelu", + "prefs.snap_drawing_note": "Piirrettäessä tai vedettäessä luonnospistettä.", + "prefs.source": "Lähde", + "prefs.speed": "Nopeus", + "prefs.stand_up_note": "Kun STEP- tai IGES-tiedosto avautuu Z ylöspäin, tarjoa neljänneskierrosta X-akselin ympäri.", + "prefs.stand_up_unknown": "Tuntemattomien ohjelmien tiedostoille", + "prefs.stand_up_unknown.ask": "Kysy valintaikkunassa", + "prefs.stand_up_unknown.note": "Näytä huomautus", + "prefs.stand_up_unknown.off": "Älä tarjoa", + "prefs.stand_up_unknown_note": "Miten tarjous tehdään, kun tiedoston otsake ei nimeä ohjelmaa, jonka tiedetään tallentavan Y ylöspäin. Huomautus näytetään vain, kun tiedosto näyttää olevan selällään; Kysy valintaikkunassa kysyy jokaisesta tällaisesta tiedostosta kuten ennenkin.", + "prefs.status_bar": "Tilarivi", + "prefs.status_bar_note": "Ohut palkki ikkunan alareunassa näyttää, mitä muokataan, avoimen luonnoksen tilan sen värillä ja mallin yksiköt; yksiköitä napsauttamalla ne vaihdetaan. Luonnoksen tila ei silloin enää näy näkymän kulmassa.", + "prefs.status_done": "Valmis", + "prefs.status_error": "Virhe", + "prefs.status_selected": "Valittu", + "prefs.status_warning": "Varoitus", + "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", + "prefs.theme_file_or_paste": "tiedosto tai liitä", + "prefs.theme_follow": "Seuraa teematiedostoa", + "prefs.theme_follow_error": "Ei otettu käyttöön: {error}. Viimeisin toimiva teema pysyy käytössä.", + "prefs.theme_follow_launch": "Seurataan vain tämän ajokerran ajan (--theme-file); ladataan uudelleen aina, kun jokin toinen työkalu kirjoittaa sen uudelleen.", + "prefs.theme_follow_note": "Ladataan uudelleen aina, kun jokin toinen työkalu kirjoittaa sen uudelleen. Teeman valitseminen lopettaa sen seuraamisen.", + "prefs.theme_follow_off": "Pois", + "prefs.theme_follow_stop": "Lopeta seuraaminen", + "prefs.theme_import_hint": "Napsauta valitaksesi teematiedoston, pudota se tähän tai paina Ctrl+V (Macissa Cmd+V), kun teema on kopioituna", + "prefs.theme_paste": "Liitä teema", + "prefs.theme_paste_none": "Leikepöydällä ei ole SolidCraft-teemaa", + "prefs.timeline_half_hour": "−30 min", + "prefs.timeline_now": "nyt", + "prefs.timeline_quarter_hour": "−15", + "prefs.toast_place": "Ilmoitukset", + "prefs.toggle_item": "Lisää kohde tai poista se uudelleen", + "prefs.toolbar": "Työkalurivi", + "prefs.toolbar_tabs": "Työkalurivin välilehdet", + "prefs.top": "Ylä", + "prefs.trackpad_zoom_modifier": "Zoomaa kahden sormen vierityksellä pitäen painettuna", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Pidä vain tätä näppäintä painettuna zoomataksesi osoittimen kohdalla pystyvierityksellä. Pois säilyttää nykyisen vierityskäyttäytymisen.", + "prefs.trackpad_zoom_modifier.off": "Pois", + "prefs.translucent": "Läpikuultava", + "prefs.under_browser": "Selaimen alla", + "prefs.under_cursor": "Kohdistimen alla", + "prefs.units_precision": "Yksiköt ja tarkkuus", + "prefs.updates": "Päivitykset", + "prefs.view": "Näkymän toiminta", + "prefs.view_confirm_corner": "OK ja Peruuta näkymän kulmassa", + "prefs.view_confirm_corner_note": "Kun valintaikkuna tai luonnos on auki, näkymän oikeassa yläkulmassa oleva valintamerkki ja risti tekevät saman kuin valintaikkunan OK ja Peruuta, tai lopettavat ja peruuttavat luonnoksen. Näkymän navigaattori siirtyy niiden alapuolelle.", + "prefs.viewport": "Näkymäikkuna", + "prefs.viewport_analysis_status": "Näytä näkymäikkunan analyysien tila", + "prefs.viewport_analysis_status_help": "Näytä aktiiviset analyysit näkymäikkunan yläpuolella, avaa niiden selitteet kustakin kohteesta ja sammuta uusin analyysi Esc-näppäimellä. Poista käytöstä palauttaaksesi kulmaselitteet ja aiemman Esc-toiminnan.", + "prefs.viewport_background": "Tausta", + "prefs.viewport_background_colour": "Väri", + "prefs.viewport_background_theme": "Teeman liukuväri", + "prefs.viewport_section_status": "Näytä näkymäikkunan leikkauksen tila", + "prefs.viewport_section_status_help": "Näytä leikkausasetus näkymäikkunassa. Esc ja aktiivinen valintanauhan painike sammuttavat sen. Poista käytöstä säilyttääksesi aiemmat leikkauksen ohjaimet.", + "prefs.wheel": "Rulla", + "prefs.wheel_trackpad": "Rulla ja kosketuslevy", + "prefs.workspace": "Työtila", + "prefs.zoom_stop_at_model": "Pysäytä zoomaus malliin", + "prefs.zoom_stop_at_model_tip": "3D-hiiri ja laitesovellus zoomaavat kohti näkymän keskellä olevaa pintaa; vetozoomaus pitää kohdan, josta veto alkoi. Pois: zoomaus voi kulkea mallin läpi. Rulla ja nipistys käyttävät erikseen asetusta Zoomaa kohdistinta kohti", + "prefs.zoom_toward_cursor": "Zoomaa kohdistinta kohti", + "properties.title": "Ominaisuudet: {name}", + "recovery.days_ago": "{count} päivää sitten", + "recovery.detail": "{file} · {features} piirrettä · tallennettu automaattisesti {when}", + "recovery.discard_all": "Hylkää kaikki", + "recovery.discard_all_detail": "Tätä ei voi kumota.", + "recovery.discard_all_title": "Hylätäänkö {count} automaattista tallennusta?", + "recovery.discard_all_title_one": "Hylätäänkö 1 automaattinen tallennus?", + "recovery.hours_ago": "{count} h sitten", + "recovery.just_now": "juuri nyt", + "recovery.min_ago": "{count} min sitten", + "recovery.never_saved": "ei koskaan tallennettu", + "render.adaptive_absolute": "RGB-virheen absoluuttinen alaraja", + "render.adaptive_coverage": "Peittovirheen tavoite", + "render.adaptive_enable": "Jaa näytteet itsenäisten pilottien avulla", + "render.adaptive_help": "Vain GPU. Itsenäiset pilottinäytteet hylätään, minkä jälkeen kullekin pikselille jaetaan vähintään 16 tuotantonäytettä enintään asetuksen Näytteitä pikseliä kohden verran. RGB- ja peittovarianssi ohjaavat jakoa; harvinaiset polut voivat jäädä huomaamatta ja rajoitetut pikselit voivat jäädä kohinaisiksi. Loppuraportti laskee todellisen työn. Kohinalle tai ajalle ei ole varmennettua takuuta.", + "render.adaptive_minimum": "Tuotantonäytteiden vähimmäismäärä", + "render.adaptive_pending": "GPU:n itsenäisiin pilotteihin perustuva mukautuva jako ja ehdolliset Fresnel-kerrostumat ovat käytettävissä. Varmennettu konvergenssi, harvinaisten tapahtumien takuut ja CPU:n mukautuva kuljetus eivät ole käytettävissä.", + "render.adaptive_pilots": "Pilottinäytteitä pikseliä kohden", + "render.adaptive_relative": "RGB-virheen suhteellinen tavoite", + "render.add_ground": "Lisää sovitettu maapinta asetelmaan", + "render.advanced_material_scope": "Yksi rajallinen homogeeninen GPU-väliaine on käytettävissä tarkoin rajoin. Tiheysruudukot, sisäkkäiset lasi- ja tilavuusyhdistelmät, pinnanalaiset pinnat, siirtymäkartoitus, spektrinen kuljetus ja sitomattomat edistyneet materiaalikäsitteet eivät ole käytettävissä.", + "render.aging_add": "Lisää vanhenemiskerros", + "render.aging_airborne": "Ilmassa oleva pöly / osallistuva väliaine", + "render.aging_airborne_help": "Ilmassa oleva pöly on erillinen rajallinen homogeeninen sirottava tilavuus. Se muuttaa kuljetusta ja raakaa EXR:ää, ei pinnan vanhenemiskerroksia. Nykyiset tilavuus-, lasi- ja kohinanpoistorajoitukset pysyvät näkyvinä; ilmakehää ei lisätä automaattisesti.", + "render.aging_all_bodies": "Kaikki kelpoiset läpinäkymättömät kappaleet", + "render.aging_chips": "Lohkeillut maali", + "render.aging_class_any": "Kaikki läpinäkymättömät materiaaliluokat", + "render.aging_class_dielectric": "Vain ei-metalliset pinnat", + "render.aging_class_metal": "Vain metalliset pinnat", + "render.aging_color": "Kerroksen väri · sRGB", + "render.aging_coverage": "Peitto", + "render.aging_dust": "Kerrostunut pöly", + "render.aging_empty": "Ei vanhenemiskerroksia: alkuperäiset pinnat pysyvät ennallaan.", + "render.aging_fading": "Haalistunut pinnoite", + "render.aging_grime": "Lika ja tahrat", + "render.aging_help": "Yhdistä enintään kahdeksan optista kerrosta järjestyksessä. Ruoste ja patina ovat laadittuja ulkoasuja, eivät ikääntymis- tai korroosiosimulaatioita. Mittakaava on maailman millimetreinä. Geometriaa, massaa tai teknisiä ominaisuuksia ei muuteta; lasi ja passiiviset näyttämöt jätetään pois.", + "render.aging_metallic": "Kerroksen metallisuusosuus", + "render.aging_origin": "Kentän origo · maailman mm", + "render.aging_patina": "Kuparinvihreä patina", + "render.aging_refresh_targets": "Tarkastele nykyisiä kappaleita", + "render.aging_roughness": "Kerroksen karheus", + "render.aging_rust": "Hapettunut rauta / ruoste", + "render.aging_scale": "Kuvion mittakaava", + "render.aging_scratches": "Hienot naarmut", + "render.aging_target": "Kohde ja koordinaatit", + "render.aging_target_help": "Nimetyn kohteen on edelleen vastattava tallennettua indeksiään ja nimeään. Yleiset maskit on ankkuroitu maailmaan; ne eivät seuraa liikkuvia osia. Tämä ei ole onkaloiden tunnistusta eikä todellista särmien kulumista. Suodata luetteloa päästäksesi muihin kappaleisiin.", + "render.aging_target_search": "Suodata kappaleiden nimiä", + "render.aging_upward": "Ylöspäin suuntautuva kerrostuminen", + "render.all_controls_help": "Kaikkien nykyisten ohjaimien tiivis tarkastelu. Osiosivuilla voi muokata kohdennetusti muuttamatta taustalla olevaa pyyntöä.", + "render.all_shots_label": "Kaikki kuvakulmat", + "render.appearance_editor.absorption": "Absorptio", + "render.appearance_editor.base_colour": "Perusväri", + "render.appearance_editor.body_reason": "Optiset sidonnat kohdistuvat yhteen kokonaiseen kappaleeseen. Muokkaa näitä ohjaimia valitsemalla Kappale.", + "render.appearance_editor.boundary": "Raja", + "render.appearance_editor.box": "Laatikko", + "render.appearance_editor.choose_image": "Valitse kuva…", + "render.appearance_editor.closed": "Suljettu kiinteä kappale", + "render.appearance_editor.coat": "Kirkaslakka", + "render.appearance_editor.cpu_reason": "Vaatii CPU-säteenjäljityksen.", + "render.appearance_editor.glass": "Lasi ja absorptio", + "render.appearance_editor.glass_enable": "Käytä fysikaalista lasia tässä kappaleessa", + "render.appearance_editor.glass_note": "Suljettu lasi käyttää geometrista etäisyyttä. Se ei voi jakaa kappaletta kirkaslakan tai karttojen kanssa.", + "render.appearance_editor.gpu_reason": "Vaatii GPU-polunjäljityksen.", + "render.appearance_editor.ior": "IOR", + "render.appearance_editor.maps": "Kartat", + "render.appearance_editor.normal": "Normaali", + "render.appearance_editor.normal_strength": "Normaalin voimakkuus", + "render.appearance_editor.origin": "Origo", + "render.appearance_editor.recipe": "Resepti", + "render.appearance_editor.reflection_sampling": "Näytteistys kaikille heijastaville kappaleille", + "render.appearance_editor.reflections": "Heijastukset", + "render.appearance_editor.remove": "Poista tallennettu", + "render.appearance_editor.remove_note": "Poista tallennettu optiikka tästä kuvakulmasta. Geometria ja määritetyt ulkoasut säilytetään.", + "render.appearance_editor.remove_title": "Poistetaanko tallennettu optiikka?", + "render.appearance_editor.replace": "Korvaa tämän kappaleen tallennettu optiikka", + "render.appearance_editor.revalidate": "Vahvista tämä kappale uudelleen", + "render.appearance_editor.save": "Tallenna tämä optiikka", + "render.appearance_editor.save_reason": "Tämän optiikan tallentaminen vaatii fysikaalisen lasin nykyisessä GPU-kappaleessa. Lakka ja paikalliset kuvakartat pysyvät tilapäisinä.", + "render.appearance_editor.saved": "Tallennettu tämän kuvakulman kanssa", + "render.appearance_editor.saved_state": "Tämä optiikka on tallennettu tämän kuvakulman kanssa.", + "render.appearance_editor.scatter_reason": "Sisäinen sironta vaatii suljetun kiinteän rajan.", + "render.appearance_editor.temporary": "Tämä optiikka on tilapäinen tällä välilehdellä. Tallenna se säilyttääksesi sen kuvakulman kanssa.", + "render.appearance_editor.thickness": "Paksuus", + "render.appearance_editor.thickness_reason": "Paksuus koskee ohuita levyjä; suljettu lasi käyttää geometrista etäisyyttä.", + "render.appearance_editor.thin": "Ohut levy", + "render.appearance_editor.u_repeat": "U-toisto", + "render.appearance_editor.v_repeat": "V-toisto", + "render.appearance_editor.v_up": "Tekstuurin V osoittaa ylöspäin", + "render.appearance_editor.weight": "Paino", + "render.appearance_physics_separate": "Visuaalinen ulkoasu ei määritä tiheyttä, lämpötietoja tai muita teknisiä ominaisuuksia.", + "render.appearances": "Ulkoasut", + "render.assign_help": "Napsauta käyttääksesi valintaan; vedä esikatsellaksesi ja pudota, tai napsauta ilman valintaa maalataksesi.", + "render.availability_native": "Alkuperäinen koostumus; tarkista ennen käyttöä", + "render.availability_projection": "Tuettu projektio; katso laajuus ja puutteet", + "render.availability_reference": "Viitetietoa; ei automaattinen määritys", + "render.backdrop_alpha_conflict": "Valitse tämä tausta tai läpinäkyvä PNG-tuloste; renderöijä ei ohita kumpaakaan asetusta huomaamatta.", + "render.backdrop_enable": "Käytä vain kameran liukuväritaustaa", + "render.backdrop_first": "Alku- / keskiväri", + "render.backdrop_help": "Tämä näyttötilan tausta ei valaise, luo varjoja, heijastu eikä kuvaa maastoa. Se jätetään pois raa'asta EXR:stä. Geometria, valot ja HDR-ympäristö pysyvät erillisinä.", + "render.backdrop_horizontal": "Vaakasuora liukuväri", + "render.backdrop_radial": "Säteittäinen liukuväri", + "render.backdrop_radius": "Säde", + "render.backdrop_second": "Loppu- / reunaväri", + "render.backdrop_vertical": "Pystysuora liukuväri", + "render.backdrop_x": "Keskipiste X", + "render.backdrop_y": "Keskipiste Y", + "render.background": "Kohtauksen tausta", + "render.background_camera_section": "Kameran tausta · PNG-esitys", + "render.background_completed_frozen": "Näytetty tulos on jäädytetty valmis kuva. Taustan muokkaaminen ei piirrä tulosta uudelleen automaattisesti. Käytä Uudelleensävytystä vain kameraa koskeviin muutoksiin tai Renderöintiä uutta fysikaalista näyttämöä varten.", + "render.background_hdr_separate": "Nykyiset HDR-resurssit valaisevat pintoja ja heijastuvat niistä; niitä ei näytetä automaattisesti panoraamana kohteen takana. Liukuvärin muuttaminen ei kierrä eikä värjää valaistusta.", + "render.background_lighting_section": "HDR-valaistus · erillään kameran taustasta", + "render.background_no_alpha_override": "Kameran taustaa ei lisätä läpinäkyvään tulosteeseen huomaamatta. Raakamasterin uudelleensävytys käyttää tallennettua peittoa eikä käytä kohinanpoistoa uudelleen; lähde-EXR pysyy ennallaan.", + "render.background_not_light": "Taustaväri on esitystä, ei lisävalonlähde.", + "render.background_regrade": "Käytä säilytettyyn raakamasteriin", + "render.background_stage_occlusion": "Tämä kuvakulma sisältää fysikaalisen näyttämön geometriaa. Suuri lattia tai seinä voi täyttää kuvan ja peittää vain kameran taustan. Tyhjennä tai muuta näyttämön kokoa tarkoituksella tai muuta sen fysikaalista heijastavuutta. Uudelleensävytys ei voi poistaa tallennettua geometriaa.", + "render.background_workflow": "Valitse haluamasi tausta: vain kameran väri tai liukuväri, valoa vastaanottava fysikaalinen näyttämö tai HDR-valaistus. Ne ovat toisistaan riippumattomia. Reaaliaikainen esikatselu on mallinnusnäkymä; renderöi uusi kuva tai sävytä säilytetty master uudelleen nähdäksesi kameran viimeistelyn.", + "render.base_appearance": "Muokattava kappaleen ulkoasu", + "render.base_apply": "Käytä kappaleen ulkoasua", + "render.base_apply_help": "Yksi kumottavissa oleva visuaalinen määritys tarkistettuun täsmälliseen kappaleeseen. Nykyiset pintakohtaiset ulkoasut ja tekniset materiaalit pysyvät erillisinä.", + "render.base_color": "Perusväri · sRGB", + "render.base_metallic": "Metallisuusosuus", + "render.base_opaque_only": "Tämä editori säilyttää läpinäkymättömän kappaleen merkityksen. Käytä optiseen läpäisyyn erillisiä lasin ohjaimia.", + "render.bloom_enable": "Näytön hehku", + "render.bloom_help": "Luova, vain tulosteeseen vaikuttava hehku positiivisista lineaarisista kohokohdista, levitetty kolmella normalisoidulla laatikkoajolla. Säde on tulosteen pikseleinä. Lisätään lineaariseen näyttövaloon ennen sRGB:tä; ei linssi- tai tilavuusefekti eikä konvergenssin parannus. Raaka EXR pysyy ennallaan.", + "render.bloom_opaque": "Hehku vaatii läpinäkymättömän tulosteen. Läpinäkyvä PNG hylätään, sitä ei litisteä huomaamatta.", + "render.bloom_radius": "Hehkun säde", + "render.bloom_strength": "Hehkun voimakkuus", + "render.bloom_threshold": "Lineaarisen kohokohdan kynnys", + "render.browse_capabilities": "Tarkastele koko ominaisuusluetteloa", + "render.browse_environments": "Selaa alkuperäisiä HDR-ympäristöjä", + "render.browse_looks": "Selaa…", + "render.browse_native_scenes": "Selaa valmiita studioita", + "render.browse_physical": "Tarkastele fysikaalisia viitteitä", + "render.browse_quality": "Tarkastele laatumäärityksiä", + "render.browse_rigs": "Selaa laadittuja valaistusasetelmia", + "render.browse_scene_specs": "Selaa edistyneitä kohtausmäärityksiä", + "render.browse_surfaces": "Selaa laadittuja pintoja", + "render.browser_library": "Kirjasto", + "render.browser_model": "Malli", + "render.camera_distance": "Silmän etäisyys kohteesta", + "render.camera_exact_help": "Maailman millimetrit; kulmat näytetään asteina ja tallennetaan radiaaneina. Navigointi käyttää samaa kameraa kuin näkymäikkuna.", + "render.camera_fov": "Pystysuora näkökenttä", + "render.camera_pitch": "Kameran nyökkäys", + "render.camera_roll": "Kallistus", + "render.camera_set": "Aseta renderöintikamera", + "render.camera_target": "Kohteen sijainti · mm", + "render.camera_yaw": "Kääntö", + "render.category_metal": "Metalli", + "render.category_paint": "Maali", + "render.category_plastic": "Muovi", + "render.category_wood": "Puu", + "render.check_opaque": "Läpinäkyvyys: vain läpinäkymättömät pinnat; taustan alfaa tuetaan.", + "render.check_sorted_alpha": "Läpinäkyvyys: lajiteltu alfa-approksimaatio.", + "render.check_transmission": "Läpinäkyvyys: fysikaalinen läpäisy.", + "render.children.absolute_floor": "Absoluuttinen alaraja", + "render.children.adaptive": "Mukautuva", + "render.children.add_look_reason": "Lisää sävy muokataksesi sen käyttötilaa ja voimakkuutta.", + "render.children.all_shots": "Kaikki kuvakulmat", + "render.children.bloom": "Hehku", + "render.children.check": "Tarkista", + "render.children.check_now": "Tarkista nyt", + "render.children.chromatic": "Kromaattinen", + "render.children.compact": "Tiivis", + "render.children.contrast": "Kontrasti", + "render.children.copy_image": "Kopioi", + "render.children.copy_job": "Kopioi työn JSON", + "render.children.copy_request": "Kopioi pyynnön JSON", + "render.children.coverage_error": "Peittovirhe", + "render.children.cpu": "CPU-säteenjäljitys", + "render.children.denoise": "Vähennä kohinaa valmiissa kuvassa", + "render.children.depth": "Polun syvyys", + "render.children.distortion": "Vääristymä", + "render.children.edge_samples": "Särmänäytteet", + "render.children.effects_banner": "Nämä efektit vaativat GPU-polunjäljityksen. Tämän avaaminen ei koskaan muuta menetelmää.", + "render.children.enable_adaptive": "Ota itsenäiset pilotit käyttöön muokataksesi mukautuvia rajoja.", + "render.children.enable_bloom": "Ota hehku käyttöön muokataksesi sen voimakkuutta, kynnystä ja sädettä.", + "render.children.finishing": "Viimeistely", + "render.children.flare": "Linssiheijastus", + "render.children.fresnel": "Fresnel-kerrostumat", + "render.children.ghosts": "Haamurenkaat", + "render.children.glass": "Lasikuljetus", + "render.children.go_to": "Siirry: {editor}", + "render.children.grain": "Rae", + "render.children.halo": "Kehä", + "render.children.hdr": "HDR", + "render.children.keep_master": "Säilytä raaka lineaarinen master", + "render.children.last_result": "Viimeisin: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Linssiefektit", + "render.children.look_summary": "{tone} · {count} sävyä", + "render.children.looks": "Luovat sävyt", + "render.children.memory": "Muisti", + "render.children.method": "Menetelmä", + "render.children.minimum": "Vähintään", + "render.children.needs_cpu": "Vaatii CPU-säteenjäljityksen. Tämän avaaminen ei koskaan muuta menetelmää.", + "render.children.nested": "Varmennetut sisäkkäiset dielektrit", + "render.children.not_checked": "Ei tarkistettu", + "render.children.path": "Polunseuranta", + "render.children.pilots": "Pilottinäytteet", + "render.children.preview": "Esikatselun hyötykuorma", + "render.children.progressive_reason": "Valitse Progressiivinen muokataksesi sen näytemäärää ja siementä.", + "render.children.radius": "Säde", + "render.children.reflect_depth": "Heijastussyvyys", + "render.children.reflect_samples": "Heijastusnäytteet", + "render.children.regrade": "Sävytä uudelleen", + "render.children.relative_error": "Suhteellinen virhe", + "render.children.render": "Renderöi", + "render.children.render_shot": "Renderöi kuvakulma", + "render.children.renderer": "Renderöijä", + "render.children.renderers": "Renderöijät", + "render.children.request": "Tämä pyyntö", + "render.children.reset_finishing": "Palauta viimeistely", + "render.children.review_quality": "Tarkista laatumääritys…", + "render.children.rgb_gain": "RGB-vahvistus", + "render.children.samples": "Näytteet", + "render.children.sampling": "Näytteistys", + "render.children.saturation": "Kylläisyys", + "render.children.scene_summary": "{bodies} kappaletta · {triangles} kolmiota", + "render.children.seed": "Siemen", + "render.children.sharpening": "Terävöinti", + "render.children.single_image": "Yksittäinen kuva", + "render.children.soft_focus": "Pehmeä tarkennus", + "render.children.streak": "Juova", + "render.children.strength": "Voimakkuus", + "render.children.studio": "Studio", + "render.children.threshold": "Kynnys", + "render.children.tone": "Sävy", + "render.children.under_hood": "Konepellin alla", + "render.children.vignette": "Vinjetti", + "render.children.workstation": "Työasema", + "render.choose_look": "Valitse ensin ulkoasu Ulkoasuista", + "render.chromatic_offset": "Kromaattinen reunasiirtymä", + "render.clear_stage": "Poista tallennettu näyttämö", + "render.copy_request": "Kopioi JSON", + "render.cpu_aa_help": "Spatiaaliset näytteet: lisänäytteet parantavat reunoja ja pidentävät renderöintiaikaa.", + "render.cpu_area_add": "Lisää suorakulmainen CPU-valo", + "render.cpu_area_editor": "CPU:n suorakulmaiset valot", + "render.cpu_area_editor_help": "Täydet sivuvektorit millimetreinä; U ristitulo V osoittaa säteilevää puolta kohti. Tämä poikkeaa GPU:n puolisärmävektoreista.", + "render.cpu_area_full_u": "Täysi U-sivu · mm", + "render.cpu_area_full_v": "Täysi V-sivu · mm", + "render.cpu_area_samples": "Aluevalon näytteet", + "render.cpu_binding_confirm": "Vahvista tämä kappale", + "render.cpu_binding_stale": "Kohtaus muuttui. Vahvista tämä heijastuskohde uudelleen ennen renderöintiä.", + "render.cpu_body_choose": "Valitse kappale", + "render.cpu_body_filter": "Suodata kappaleita nimen mukaan", + "render.cpu_body_filter_more": "Tarkenna suodatinta nähdäksesi lisää kappaleita.", + "render.cpu_body_missing": "Heijastuskohde puuttuu, on piilotettu tai monitulkintainen tai sen nimi on yli 128 tavua pitkä. Valitse se uudelleen.", + "render.cpu_budget": "CPU-resurssiprofiili", + "render.cpu_budget_bodies": "Kappaleiden yläraja", + "render.cpu_budget_compact": "Tiivis (vanhat rajat)", + "render.cpu_budget_details": "Resurssirajat", + "render.cpu_budget_help": "Valitse suuremmat rajat erikseen. Tarkkuutta ja näytteitä ei koskaan pienennetä automaattisesti.", + "render.cpu_budget_memory": "Työn muistin yläraja", + "render.cpu_budget_not_guarantee": "Nämä ovat hyväksymisen ylärajoja, eivät muistivaraus tai nopeustakuu. Geometria, filmi ja säilytetyt esikatselut jakavat muistibudjetin; renderöinnin voi aina peruuttaa.", + "render.cpu_budget_resize": "Luonnos ylittää tämän CPU-profiilin tai sen kameranäytebudjetin. Hyväksy pienemmät mitat erikseen tai valitse suurempi profiili.", + "render.cpu_budget_studio": "Studio (suuret kuvat)", + "render.cpu_budget_triangles": "Kolmioiden yläraja", + "render.cpu_budget_workstation": "Työasema (raskaat kohtaukset)", + "render.cpu_captured_hdr": "Tätä kuvaa varten tallennettu HDR", + "render.cpu_check_capture_help": "Tarkista ja Aloita varmistavat saman tiedostotiivisteen tai käyttävät upotettua HDR:ää. Kukin kuva säilyttää tallennetun lähteensä.", + "render.cpu_check_changed": "Ohjaimet tai malli muuttuivat tämän tarkistuksen jälkeen. Tarkista uudelleen ennen renderöintiä.", + "render.cpu_constant_help": "Vakiosäteily näkyy heijastuneille tai läpäisseille säteille. Se ei anna suoraa diffuusia valaistusta.", + "render.cpu_custom_directions": "Korvaa esiasetus omilla suunnatuilla valoilla", + "render.cpu_direction": "Suunta valoa kohti (X, Y, Z)", + "render.cpu_directional": "Suunnattu valo", + "render.cpu_directional_add": "Lisää suunnattu valo", + "render.cpu_environment": "CPU-ympäristö", + "render.cpu_environment_background": "Kuvan tausta pysyy erillään ympäristövalaistuksesta.", + "render.cpu_environment_bounds": "CPU-voimakkuuden on oltava 0–64, HDR-kierron äärellinen ±1,000,000 radiaanin sisällä ja HDR-näytteitä 1, 4, 16 tai 64.", + "render.cpu_environment_constant": "Vakio toissijaisten säteiden ympäristö", + "render.cpu_environment_none": "Ei CPU-ympäristöä", + "render.cpu_environment_samples": "HDR-valaistuksen näytteet", + "render.cpu_exposure_help": "CPU-valotuksen on oltava välillä −16 ja 16 EV. Tämän välin ulkopuolinen GPU-valotus säilytetään, kunnes muutat sitä.", + "render.cpu_fixed": "Yksittäinen kuva", + "render.cpu_hdr_help": "Radiance .hdr, 2:1-panoraama, enintään 2048 × 1024 ja 16 MiB. CPU vaatii tiedostoon eksplisiittiset sRGB/D65-pääväripisteet; määrittämättömät pääväripisteet ja EXR hylätään.", + "render.cpu_hdr_reflection_help": "HDR:n karheat heijastukset vaativat täysin metalliset kappaleen ja pintojen materiaalit. Tarkistus raportoi tukemattomat yhdistelmät.", + "render.cpu_key_rim": "Pää- ja reunavalo", + "render.cpu_last_check": "Viimeisin CPU-tarkistus", + "render.cpu_light_help": "Enintään kahdeksan suunnattua valoa. Tyhjä oma luettelo sammuttaa ne; esiasetuksen valitseminen palauttaa sen valot. Värit ovat lineaarista RGB:tä ja voimakkuus on suhteellinen, ei watteja.", + "render.cpu_light_intensity": "Suhteellinen voimakkuus (0–64)", + "render.cpu_light_minimum": "Vaimenemisen vähimmäisetäisyys", + "render.cpu_light_position": "Maailman sijainti (X, Y, Z)", + "render.cpu_light_reference": "Voimakkuuden viite-etäisyys", + "render.cpu_light_remove": "Poista valo", + "render.cpu_lighting": "Valaistuksen esiasetus", + "render.cpu_lights": "CPU-valot", + "render.cpu_linear_color": "Lineaarinen väri (R, G, B)", + "render.cpu_neutral": "Neutraali", + "render.cpu_opaque_required": "CPU-säteenjäljitys vaatii läpinäkymättömän taustan. Jatka poistamalla valinta Läpinäkyvä tausta.", + "render.cpu_options_pending": "Tällä CPU-paneelilla ei voi vielä muokata emissiota, pintatehosteita, lasia eikä anisotropiaa. Tyhjennä nämä API-asetukset ennen sen käyttöä.", + "render.cpu_point": "Pistevalo", + "render.cpu_point_add": "Lisää pistevalo", + "render.cpu_point_help": "Enintään kahdeksan pistevaloa, jotka lisätään valitun suuntavalaistuksen päälle. Etäisyydet ovat millimetreinä; vähimmäisetäisyys ei saa ylittää viite-etäisyyttä.", + "render.cpu_primary_samples": "Särmänäytteitä pikseliä kohden", + "render.cpu_progressive": "CPU progressiivinen", + "render.cpu_progressive_help": "Kerryttää nykyisen CPU-integraattorin tulosta yhden ensisijaisen näytteen kierroksina. Valitut heijastukset edellyttävät yhtä näytettä; aluevalot, dielektrit ja anisotropia edellyttävät yksittäiskuvaa.", + "render.cpu_progressive_limits": "Enintään 1 048 576 pikseliä ja 16 777 216 pikseli × näyte -laskentaa. Näytemäärän muuttaminen ei koskaan muuta kuvan kokoa automaattisesti.", + "render.cpu_progressive_seed": "CPU-näytteistyksen siemenluku", + "render.cpu_progressive_spp": "näytettä pikseliä kohden", + "render.cpu_reflection_add": "Lisää karhea heijastus", + "render.cpu_reflection_depth": "Heijastussyvyys (1–8)", + "render.cpu_reflection_empty": "Kappaleita ei ole valittu: näkymän heijastukset ovat pois päältä.", + "render.cpu_reflection_help": "Valitse kappaleet ja aseta Pois, Peili tai Karhea. Karhea käyttää määritettyä pinnan karheutta. Useammat näytteet ja heijastuskerrat pidentävät laskenta-aikaa; tämä on rajattua peiliheijastusten kulkua, ei globaalia valaistusta.", + "render.cpu_reflection_limit": "Enintään 64 heijastavaa kappaletta on tuettu.", + "render.cpu_reflection_mirror": "Peili", + "render.cpu_reflection_off": "Pois", + "render.cpu_reflection_rough": "Karhea", + "render.cpu_reflection_samples": "Heijastusnäytteet (1–64)", + "render.cpu_reflections": "CPU-heijastukset", + "render.cpu_runtime_help": "Numeeriset valaistus- ja ympäristöasetukset tallennetaan asetelman mukana. Kappaleiden heijastukset ja HDR-tiedostot ovat väliaikaisia. Olemassa olevat aluevalo- ja kohdevalokartioarvot säilytetään.", + "render.cpu_sampling_mode": "CPU-näytteistystila", + "render.cpu_shadows": "Heitä varjot", + "render.cpu_summary": "CPU-säteenjäljitys · heijastukset · HDR · suunta- ja pistevalot", + "render.cpu_warm_cool": "Lämmin ja viileä", + "render.diag_bodies": "Näkyvät kappaleet", + "render.diag_job_details": "Tallennetun työn tiedot", + "render.diag_last_check": "Viimeisin esitarkistus", + "render.diag_memory": "Looginen hyväksyntäarvio", + "render.diag_not_implemented": "Ominaisuuksien rajat", + "render.diag_preflight_details": "Esitarkistuksen tiedot", + "render.diag_request": "Nykyinen pyyntö tarkalleen", + "render.diag_triangles": "Kolmiot", + "render.discard_continue": "Hylkää luonnos ja jatka", + "render.draft_confirm_help": "Nykyisessä renderöintiluonnoksessa on käyttämättömiä muutoksia. Tallenna ne ennen vaihtamista, hylkää ne erikseen tai jatka muokkaamista.", + "render.draft_confirm_title": "Säilytä muokatut renderöintiasetukset", + "render.effects_gpu_only": "Nämä tehosteet edellyttävät GPU-polunjäljitystä. Sivun vaihtaminen ei koskaan vaihda renderöijää automaattisesti.", + "render.effects_help": "Yhdistä järjestettyjä värisävyjä ja kuva-avaruuden objektiivitehosteita. Renderöinti ja raakamasterin uudelleenvärimäärittely käyttävät samoja tallennettuja asetuksia. Raaka-EXR pysyy muuttumattomana. Nämä ovat alkuperäisiä taiteellisia säätimiä, eivät kalibroituja objektiivi- tai tuotemerkkifilmijäljitelmiä.", + "render.effects_reset_lens": "Palauta objektiivitehosteet", + "render.effects_seed": "Kuvion siemenluku", + "render.emitted_axis": "Säteilyakseli", + "render.env_advanced": "Lisäasetukset", + "render.env_backdrop": "Taustakulissi", + "render.env_backdrop_pending": "Renderöijä ei vielä tue ympäristön näkyvyyttä eikä taustan sumennusta.", + "render.env_blur": "Sumennus", + "render.env_change": "Vaihda", + "render.env_choose_source": "Valitse ympäristö kohdasta Studiot ja ympäristöt ennen valaistuksen muokkaamista.", + "render.env_clear": "Tyhjennä", + "render.env_colour": "Väri", + "render.env_environments": "Ympäristöt", + "render.env_ground": "Maa", + "render.env_ground_pending": "Fyysinen maa vastaanottaa varjot rajallisena diffuusina pintana. Pelkkien varjojen sieppausta ja maaheijastuksia ei vielä tueta.", + "render.env_intensity": "Voimakkuus", + "render.env_invalid_edit": "Virheellinen ympäristön muokkaus.", + "render.env_physical_ground": "Fyysinen maa", + "render.env_preview_help": "Havainnollistava pallonäyte; ei valitun ympäristön esikatselu.", + "render.env_reflection": "Heijastus", + "render.env_ring_help": "Kierrä valaistusta vetämällä rengasta. Pidä Shift painettuna, niin kierto tarttuu 15° välein.", + "render.env_rotate": "Kierrä", + "render.env_set": "Aseta renderöintiympäristö", + "render.env_shadow_catcher": "Varjonsieppaaja", + "render.env_show_environment": "Näytä ympäristö", + "render.env_source": "Lähde", + "render.env_stage_details": "Maan muokkaus edellyttää säteenjäljitystä ja yhtä maapaneelia. Muokkaa muita näyttämöpaneeleja kohdassa Näkymä.", + "render.env_studios": "Studiot", + "render.environment.albedo": "Albedo", + "render.environment.atmosphere": "Ilmakehä", + "render.environment.backdrop_note": "Taustakulissi näkyy vain kameralle. Se ei koskaan valaise, heitä varjoja eikä heijastu, ja se hylätään läpinäkyvän tulosteen kanssa.", + "render.environment.blur_pending": "Sumennus ei ole käytettävissä: renderöijässä ei ole taustakulissin sumennusasetusta.", + "render.environment.builtin": "Sisäänrakennettu HDR", + "render.environment.check": "Tarkista tiedosto", + "render.environment.checked": "HDR-tiedosto tarkistettu. Tallennus upottaa nämä tavut.", + "render.environment.choose": "Valitse HDR…", + "render.environment.constant": "Vakio", + "render.environment.cyclorama": "Syklorama", + "render.environment.embedded": "Tallennettu tämän mallin sisään", + "render.environment.end": "Loppu", + "render.environment.extinction": "Ekstinktio", + "render.environment.file": "Tiedosto", + "render.environment.fit": "Sovita ja tallenna maa", + "render.environment.floor_wall": "Lattia ja seinä", + "render.environment.gap": "Välys", + "render.environment.gradient": "Liukuväri", + "render.environment.gradient_gpu": "Liukuvärinen taustakulissi edellyttää GPU-polunjäljitystä.", + "render.environment.ground": "Maa ja näyttämö", + "render.environment.hdr": "HDR", + "render.environment.hdr_file": "HDR-tiedosto", + "render.environment.horizon": "Horisontti", + "render.environment.horizontal": "Vaakasuora", + "render.environment.keep": "Säilytä", + "render.environment.lighting_samples": "Valaistusnäytteet", + "render.environment.margin": "Laajuuden marginaali", + "render.environment.max": "Enimmäisrajat", + "render.environment.medium": "Osallistuva väliaine", + "render.environment.min": "Vähimmäisrajat", + "render.environment.mode": "Tila", + "render.environment.needs_gpu": "Edellyttää GPU-polunjäljitystä.", + "render.environment.none": "Ei mitään", + "render.environment.orientation": "Suunta ja voimakkuus", + "render.environment.phase": "Vaihe", + "render.environment.primaries": "Käsittele määrittelemättömät päävärit lineaarisena sRGB:nä", + "render.environment.primaries_gpu": "Määrittelemättömien päävärien tulkinta edellyttää GPU-polunjäljitystä.", + "render.environment.radial": "Säteittäinen", + "render.environment.radial_only": "Keskipiste ja säde koskevat säteittäistä tilaa.", + "render.environment.reflectance": "Heijastavuus", + "render.environment.remove": "Poista näyttämö", + "render.environment.remove_confirm": "Poistetaanko tallennettu näyttämö?", + "render.environment.remove_note": "Poistaa tästä kuvakulmasta {count} passiivista paneelia. Kameran taustakulissi säilyy.", + "render.environment.replace": "Vahvista olemassa olevan näyttämön korvaaminen", + "render.environment.rotation_hdr": "Kierto edellyttää HDR-lähdettä CPU-säteenjäljityksessä.", + "render.environment.samples_cpu": "GPU-polunjäljityksen budjetti määrää valaistusnäytteet; tämä asetus edellyttää CPU-säteenjäljitystä.", + "render.environment.saved_panels": "Tallennetut paneelit {count}", + "render.environment.sky_ground": "Maa", + "render.environment.sky_note": "Alkuperäinen taivasliukuväri valaistukseen; ei kalibroitu ilmakehä.", + "render.environment.source_raytrace": "Ympäristölähteet edellyttävät CPU- tai GPU-säteenjäljitystä.", + "render.environment.stage_raytrace": "Fyysiset näyttämöt edellyttävät CPU- tai GPU-säteenjäljitystä.", + "render.environment.start": "Alku", + "render.environment.vertical": "Pystysuora", + "render.environment.world": "Maailma", + "render.environment.world_pending": "Maailmataustaa ei ole käytettävissä: ympäristön kameranäkyvyydellä ei ole vielä tallennettavaa asetusta.", + "render.environment.zenith": "Zeniitti", + "render.environment_tiles_require_raytrace": "Ympäristöt edellyttävät CPU- tai GPU-säteenjäljitystä. Valitse se Tuloste-editorissa.", + "render.error_area_samples": "area_samples-arvon on oltava 1, 4, 16 tai 64", + "render.error_binding_allocation": "materiaalisidontaa ei voitu varata", + "render.error_body_bound": "tuote-esikatselu ylittää tallennettujen kappaleiden rajan", + "render.error_checkpoint_dimensions": "tarkistuspisteen mitat poikkeavat tallennetusta pyynnöstä", + "render.error_decode_budget": "kuvan purkubudjetti ylittyi", + "render.error_denoise_setting": "virheellinen natiivi kohinanpoistoasetus", + "render.error_display_dimensions": "virheelliset kuvan näyttömitat", + "render.error_display_fit": "kuvan näyttö ei mahdu tälle laitteelle", + "render.error_display_height": "näytön korkeus ylittyi", + "render.error_display_width": "näytön leveys ylittyi", + "render.error_duplicate_face": "tuotteen pinnan ulkoasu on kahteen kertaan", + "render.error_export_document": "Renderöinnin vientiasiakirja muuttui kohteen valinnan aikana", + "render.error_face_cap": "tuote-esikatselu ylittää pintojen ulkoasujen hyväksyntärajan", + "render.error_finish_gesture": "viimeistele mallinnuseleet ennen esikatselua", + "render.error_finish_operation": "viimeistele nykyinen mallinnustoiminto ennen siirtymistä renderöintiin", + "render.error_fixed_preview": "kiinteä studioesikatselu edellyttää valotusta 0, ympäristön kiertoa 0 ja voimakkuutta 1", + "render.error_geometry_size": "tuotegeometrian koko ylittyi", + "render.error_glass_recipe": "tuntematon lasiresepti", + "render.error_gpu_preview": "GPU-tuote-esikatselu ei ole käytettävissä tässä isännässä. Tallennetut asetelmat ovat edelleen käytettävissä.", + "render.error_gpu_samples": "GPU-näytemäärää ei tueta", + "render.error_image_dimensions": "renderöidyn kuvan mitat eivät vastaa sen työtä", + "render.error_image_profile": "renderöity kuva ylittää tallennetun resurssiprofiilinsa", + "render.error_interactive_command": "sitomaton interaktiivinen renderöintikomento", + "render.error_job_action": "ei interaktiivinen kuvatyötoiminto", + "render.error_job_identity": "virheellinen renderöintityön tunniste: {error}", + "render.error_library_reply": "virheellinen renderöintikirjaston vastaus", + "render.error_material_binding": "materiaalisidonta puuttuu", + "render.error_no_completed_image": "renderöinnillä ei ole valmista kuvaa", + "render.error_no_geometry": "ei tuotegeometriaa", + "render.error_no_scene": "ei tuotenäkymää", + "render.error_opacity": "virheellinen tuotteen ulkoasun peittävyys", + "render.error_pbr_opacity": "peittävä PBR edellyttää peittävyyttä 1", + "render.error_physical_glass": "valittu resepti ei ole fyysinen lasi", + "render.error_preview_device": "tuote-esikatselu edellyttää asennettua laitetta, jossa on syvyyspuskuri", + "render.error_product_triangle": "virheellinen tuotekolmio", + "render.error_provisional_identity": "virheellinen väliaikainen renderöintitunniste tai näytemäärä", + "render.error_quality_bounds": "laatuprojektio ylittää natiivit rajat", + "render.error_quality_method": "laatuprojektio edellyttää samaa eksplisiittistä GPU-menetelmää", + "render.error_quality_projection": "virheellinen natiivi laatuprojektio", + "render.error_regrade_identity": "virheellinen uudelleenvärimäärittelyn tunniste: {error}", + "render.error_scene_bodies": "renderöintinäkymän kappaleet puuttuvat", + "render.error_scene_body_limit": "renderöintinäkymä ylittää kappalerajansa", + "render.error_scene_identity": "virheellinen renderöintinäkymän tunniste", + "render.error_setup_panicked": "GPU-polunjäljittimen alustus kaatui", + "render.error_setup_stopped": "alustustyöprosessi pysähtyi palauttamatta isäntää", + "render.error_setup_worker": "alustustyöprosessia ei voitu käynnistää: {error}", + "render.error_source_preview": "lähde-esikatselu ylittää tavurajansa", + "render.error_surface_normals": "PBR edellyttää nollasta poikkeavia pintanormaaleja", + "render.error_surface_reply": "virheellinen laaditun pinnan vastaus", + "render.error_triangle_bound": "tuote-esikatselu ylittää kahdeksan miljoonan kolmion lähderajan", + "render.error_triangle_count": "tuotteen kolmiomäärä ylittyi", + "render.error_upload_slot": "tuote-esikatselun latauspaikka ei ole käytettävissä", + "render.error_vertex_allocation": "tuote-esikatselun kärkipisteiden varaus epäonnistui", + "render.error_vertex_envelope": "tuote-esikatselu ylittää {limit} MiB:n kärkipistedatan rajan; geometriaa ei yksinkertaistettu", + "render.error_vertex_packing": "tuote-esikatselun pakkaus poikkeaa hyväksytystä kärkipistedatasta", + "render.error_visible_solid": "Luo näkyvä kiinteä kappale Suunnittelu-tilassa, niin voit esikatsella sitä täällä.", + "render.exit_focus": "Poistu kohdistuksesta", + "render.export_display": "Vie: näyttö-PNG", + "render.export_image_label": "Vie kuva", + "render.export_linear": "Vie: lineaarinen raaka-EXR", + "render.exr_pending": "GPU voi säilyttää lineaarisen raaka-EXR:n. Pakkaus, monikerroksiset kuvat ja apurenderöintikierrokset eivät ole käytettävissä.", + "render.film_grain": "Valokuvarakeisuus", + "render.fit": "Sovita tuote", + "render.fixed_camera": "Kamera", + "render.fixed_world": "Maailma", + "render.flare_ghosts": "Haamurenkaita lähdettä kohden", + "render.flare_halo": "Heijastuman halo", + "render.flare_streak": "Anamorfinen juova", + "render.flare_strength": "Kohokohtien heijastuman voimakkuus", + "render.flare_threshold": "Heijastuman kynnys (näkymälineaarinen)", + "render.fold_browser": "Kutista selain", + "render.frame_aspect": "Kuvasuhde", + "render.frame_centre_mark": "Keskimerkki", + "render.frame_copy_limit": "Vapaata kuvakulman kopionimeä ei ole käytettävissä", + "render.frame_copy_name": "{name} kopio {number}", + "render.frame_custom": "Mukautettu", + "render.frame_distance_unit": " × koko", + "render.frame_duplicate": "Monista", + "render.frame_exposure": "Valotus", + "render.frame_fov": "Näkökenttä", + "render.frame_guides": "Apuviivat", + "render.frame_invalid_aspect": "Virheellinen kuvasuhde-esiasetus", + "render.frame_lens": "Näkökenttä {fov}° · ≈ {mm} mm", + "render.frame_name": "Nimi", + "render.frame_output_size": "Tulosteen koko", + "render.frame_photo": "3:2 · Valokuva", + "render.frame_portrait": "4:5 · Pysty", + "render.frame_presets": "Rajausesiasetukset", + "render.frame_presets_pending": "Tulee kuvakulmaeditorin myötä", + "render.frame_projection": "Projektio", + "render.frame_render_shot": "Renderöi tämä kuvakulma", + "render.frame_rule_thirds": "Kolmanneksen sääntö", + "render.frame_safe_area": "Turva-alue", + "render.frame_shot_title": "Kuvakulma · {name}", + "render.frame_square": "1:1 · Neliö", + "render.frame_tall": "9:16 · Pysty", + "render.frame_thirds": "Kolmannekset", + "render.frame_wide": "16:9 · Laajakuva", + "render.gpu_depth": "Polun enimmäissyvyys", + "render.gpu_options_help": "Näytteet, syvyys, siemenluku ja valotus tallennetaan tämän asetelman mukana. Vain aktiivinen kuvamenetelmä tallennetaan.", + "render.gpu_path_failed": "GPU-polunjäljitin ei ole käytettävissä", + "render.gpu_path_first_setup": "Ensimmäinen alustus tällä GPU:lla voi kestää useita minuutteja; SolidCraftia voi käyttää sillä aikaa. Myöhemmät käynnistykset käyttävät käännettyjä varjostimia uudelleen, jos ajuri säilyttää ne.", + "render.gpu_path_preparing": "GPU-polunjäljitin: käännetään varjostimia…", + "render.gpu_seed": "Satunnaissiemen", + "render.gpu_unavailable": "GPU-polunjäljitys ei ole valmis tällä laitteella.", + "render.ground_apply": "Sovita ja tallenna maa", + "render.ground_gap": "Eksplisiittinen välys", + "render.ground_linear_rgb": "Diffuusi heijastavuus · lineaarinen RGB", + "render.ground_margin": "Maan laajuuden marginaali", + "render.hdr_details": "HDR-lähteen tiedot", + "render.hdr_embedded": "HDR tallennettu tämän mallin sisään", + "render.hdr_embedded_help": "Renderöinti käyttää malliin sisällytettyjä tavuja; alkuperäistä tiedostoa ei enää tarvita.", + "render.hdr_file_captured": "HDR-tiedosto tarkistettu. Tallennus upottaa nämä tavut; jos tiedosto muuttuu, tarkista se uudelleen erikseen.", + "render.hdr_inspect": "Tarkista HDR-tiedosto", + "render.hdr_inspect_required": "Valitse ja tarkista HDR-tiedosto ennen tallennusta tai renderöintiä.", + "render.hdr_primaries_declared": "Lähde ilmoittaa päävärinsä.", + "render.hdr_primaries_srgb": "Päävärit: sRGB/D65", + "render.hdr_primaries_undeclared": "Lähde ei ilmoita päävärejä; tarkista renderöijän värivaatimukset.", + "render.hdr_resource_missing": "Tästä mallista puuttuu tallennettu HDR-resurssi.", + "render.heading_batch": "ERÄ", + "render.heading_frame": "RAJAUS", + "render.heading_lens": "OBJEKTIIVI", + "render.heading_results": "TULOKSET", + "render.heading_shots": "KUVAKULMAT", + "render.hide_shots_label": "Piilota kuvakulmanauha", + "render.hover_look": "{object} · {look} · valitse napsauttamalla", + "render.image_advanced": "GPU:n lisäsäätimet", + "render.image_cancelled": "Renderöinti peruttu", + "render.image_cancelling": "Perutaan", + "render.image_completed": "Kuva valmis vietäväksi", + "render.image_encoding": "Koodataan PNG:tä", + "render.image_export": "Vie valmis kuva", + "render.image_exposure": "Valotus", + "render.image_failed": "Renderöinti epäonnistui", + "render.image_height": "Korkeus", + "render.image_help": "CPU-esikatselukuva · PNG · likimääräinen läpinäkyvyys", + "render.image_host_missing": "Kuvaisäntää ei ole asennettu.", + "render.image_method": "Kuvamenetelmä", + "render.image_overwrite": "Korvaa olemassa oleva tiedosto", + "render.image_preparing": "Valmistellaan kuvaa", + "render.image_progressive": "Progressiivinen renderöinti", + "render.image_queued": "Odottaa renderöintiä", + "render.image_reduce": "Kokeile pienempää kuvaa tai yksinkertaisempaa näkymää.", + "render.image_running": "Renderöidään kuvaa", + "render.image_samples": "Näytteitä pikseliä kohden", + "render.image_scene_stale": "Viimeistele mallinnuseleet tai päivitä näkymä ennen kuvan tarkistamista tai aloittamista.", + "render.image_stale": "Tämä kuva käyttää näkymää, joka tallennettiin ennen myöhempiä muutoksia.", + "render.image_start": "Aloita renderöinti", + "render.image_supported": "Renderöintipyyntö on tuettu; jono ja muistivaranto tarkistetaan uudelleen aloitettaessa.", + "render.image_title": "Renderöi kuva", + "render.image_too_large": "Kuvaraja ylittyy: 4096 pikseliä sivua kohden ja yhteensä 4 194 304 pikseliä.", + "render.image_transparent": "Läpinäkyvä tausta", + "render.image_unknown": "Renderöinnin tila ei ole saatavilla", + "render.image_use_largest": "Käytä suurinta kokoa tällä kuvasuhteella:", + "render.image_width": "Leveys", + "render.include_rig_cards": "Sisällytä passiiviset heijastinlevyt ja varjostimet", + "render.inner_half_angle": "Sisempi puolikulma", + "render.inspect_scene": "Päivitä tallennetun näkymän yhteenveto", + "render.inspector": "Tarkastelija", + "render.inspector_advanced": "Lisäasetukset", + "render.inspector_all_users": "Kaikki {count} käyttäjää", + "render.inspector_applies_to": "Koskee kohdetta", + "render.inspector_both_users": "Molemmat käyttäjät", + "render.inspector_chain": "Mistä tämä ulkoasu tulee", + "render.inspector_colour": "Väri", + "render.inspector_edit": "Muokkaa", + "render.inspector_finish": "Pintakäsittely", + "render.inspector_from_material": "Materiaalista", + "render.inspector_gloss": "Kiiltävä", + "render.inspector_material": "Materiaali", + "render.inspector_matte": "Matta", + "render.inspector_metal": "Metalli", + "render.inspector_opacity": "Peittävyys", + "render.inspector_opaque_help": "Pintakäsittely- ja optiset kentät käyttävät peittävää PBR:ää (peittävyys 1). Pienempi peittävyys käyttää skalaarivarjostusta.", + "render.inspector_pick_target": "Valitse tämäntyyppinen kohde tarkastellaksesi sen ulkoasua.", + "render.inspector_remove": "Poista ohitus", + "render.inspector_replace": "Korvaa…", + "render.inspector_reset": "Palauta", + "render.inspector_satin": "Satiini", + "render.inspector_save_library": "Tallenna kirjastoon…", + "render.inspector_set_all": "Sekalaisia ulkoasuja. Muokkaus asettaa ulkoasun kaikille valituille kohteille.", + "render.inspector_soon": "Tulossa", + "render.inspector_target_limit": "Valitse enintään 10000 kohdetta ulkoasujen tarkastelua varten.", + "render.inspector_textures": "Tekstuurit", + "render.inspector_this_body": "Tämä kappale", + "render.inspector_this_component": "Tämä komponentti", + "render.inspector_this_face": "Tämä pinta", + "render.inspector_this_target": "Tämä kohde", + "render.inspector_used_by": "Käyttäjiä {count}", + "render.keep_editing": "Jatka muokkaamista", + "render.layer_down": "Siirrä alas", + "render.layer_enabled": "Kerros käytössä", + "render.layer_off_suffix": " (pois)", + "render.layer_remove": "Poista kerros", + "render.layer_strength": "Kerroksen voimakkuus", + "render.layer_up": "Siirrä ylös", + "render.lens_effects_help": "Heijastuma käyttää enintään neljää erillistä kirkasta lähdettä varsinaisessa lineaarisessa kuvassa. Haamut, halo, juovat, kromaattiset siirtymät ja säteittäinen vääristymä ovat taiteellisia kuva-avaruuden likiarvoja. Tilatehosteet edellyttävät peittävää tulostetta; reunan ulkopuoliset näytteet jatkavat kuvan reunaa. Rakeisuus on deterministinen. Fysiikkaa, EXR:ää tai geometriaa ei muuteta.", + "render.lens_effects_title": "Objektiivi- ja valokuvatehosteet", + "render.library_all": "Luettelo", + "render.library_all_states": "Kaikki saatavuustilat", + "render.library_appearance_available": "Käytettävissä oleva ulkoasu", + "render.library_applicable": "Sovellettavat resurssit", + "render.library_applied": "Resurssiprojektio otettu käyttöön. Tarkista koko renderöintipyyntö ennen aloittamista.", + "render.library_apply_appearance": "Käytä ulkoasua", + "render.library_apply_hdr": "Tallenna tällä HDR-ympäristöllä", + "render.library_apply_hint": "Käytä napsauttamalla · vedä mallin päälle", + "render.library_apply_maps": "Käytä laadittuja GPU-pintakarttoja", + "render.library_apply_studio": "Tallenna laadittu studioprojektio", + "render.library_apply_uniform": "Käytä yhtenäistä peittävää ulkoasua", + "render.library_authored_help": "Alkuperäiset laaditut resurssit ovat havainnollistavia, eivät mitattua suunnittelutietoa. Kukin toiminto soveltaa vain ilmoitettua projektiotaan.", + "render.library_blocked": "Estetty / edellyttää kalibrointia", + "render.library_capabilities": "Ominaisuusmäärittelyt", + "render.library_center": "Valaisinasetelman keskipiste", + "render.library_clear_search": "Tyhjennä", + "render.library_details": "Määrittely ja alkuperä", + "render.library_emitters_only_ack": "Käytä vain valonlähteitä; jätä dokumentoidut passiiviset heijastinlevyt pois", + "render.library_empty": "Ei vastaavia resursseja. Kokeile toista hakua tai saatavuussuodatinta.", + "render.library_environment_concepts": "Ympäristökonseptit", + "render.library_extent": "Kohteen laajuus", + "render.library_favorite": "Lisää suosikkeihin", + "render.library_favorites": "Suosikit", + "render.library_filters": "Luettelo ja saatavuus", + "render.library_grid": "Ruudukko", + "render.library_hdr": "Sisäänrakennetut HDR-ympäristöt", + "render.library_hdr_help": "Upottaa alkuperäisen HDR:n tallennettuun asetelmaan. Laadittu pituusasteen origo muunnetaan erikseen; ulkoista tiedostoa ei tarvita tallennuksen jälkeen.", + "render.library_in_design": "Suunnittelussa", + "render.library_inspect_only": "Valitse tarkastellaksesi; mitään ei oteta käyttöön automaattisesti. Suosikit ja viimeaikainen historia kuuluvat tähän avoimeen asiakirjaistuntoon.", + "render.library_ior": "Taitekerroin", + "render.library_light_concepts": "Valaistuskonseptit", + "render.library_list": "Luettelo", + "render.library_maps_help": "Sisäänrakennetut kartat voidaan tallentaa kiinnitettyjen lähteiden ja paikallisen esiintymäkohdistuksen kanssa. Väliaikaiset optiset ohitukset ovat edelleen erillinen tila.", + "render.library_material_concepts": "Materiaalikonseptit", + "render.library_metallic": "Metallisuus", + "render.library_native_scenes": "Natiivit studiot", + "render.library_next": "Seuraava sivu", + "render.library_omitted": "Ei sisällytetty", + "render.library_opacity": "Peittävyys", + "render.library_other": "Muut ulkoasut", + "render.library_physical_profiles": "Fyysiset viiteprofiilit", + "render.library_previous": "Edellinen sivu", + "render.library_quality_concepts": "Laatumäärittelyt", + "render.library_recent": "Viimeaikaiset", + "render.library_replace_environment": "Korvaa olemassa oleva ympäristö erikseen", + "render.library_replace_studio": "Korvaa tallennettu valaistus, ympäristö ja näyttämö", + "render.library_results": "Vastaavat resurssit", + "render.library_rig_help": "Sovita kerran eksplisiittiseen keskipisteeseen ja fyysiseen laajuuteen. Täydelliset valaisinasetelmat sisältävät passiiviset heijastinlevyt; kalibrointiesteet hylkäävät edelleen. Valonlähteiden ja levyjen varovainen välys tarkistetaan ennen tallennusta.", + "render.library_rigs": "Laaditut valaisinasetelmat", + "render.library_roughness": "Karheus", + "render.library_save_maps": "Tallenna kartat tähän asetelmaan", + "render.library_scene_bounds": "Käytä tallennettuja kohtauksen rajoja", + "render.library_scenes": "Näkymämäärittelyt", + "render.library_search": "Hae kirjastosta", + "render.library_select_hint": "Valitse resurssi kirjastosta tarkastellaksesi sen tuettuja toimintoja ja rajoituksia.", + "render.library_selene": "SELENE-peittävät ulkoasut", + "render.library_selene_shots": "SELENE-kuvakulmamäärittelyt", + "render.library_short_blocked": "Ei käytettävissä", + "render.library_short_native": "Natiivi koostumus", + "render.library_short_projection": "Tuettu projektio", + "render.library_short_reference": "Vain viite", + "render.library_short_specification": "Vain määrittely", + "render.library_source_preview": "Lataa lähdekartan esikatselu", + "render.library_source_preview_help": "Laadittu perusvärin lähdekartta. Tämä ei ole renderöity eikä mitattu materiaalin esikatselu.", + "render.library_specification_only": "Vain määrittely: ei täydellinen suoritettava esiasetus. Vaaditut tehosteet, resurssit ja kohderoolit tarvitsevat yhä eksplisiittisen toteutuksen ja sidonnan.", + "render.library_specs": "Vain määrittelyt", + "render.library_status": "Saatavuus", + "render.library_surfaces": "Laaditut PBR-pinnat", + "render.library_target": "Kohdekappale", + "render.library_title": "Renderöinnin resurssikirjasto", + "render.library_unfavorite": "Poista suosikeista", + "render.library_uniform_ack": "Käytä yhtenäistä likiarvoa ilman tila- tai lisätehosteita", + "render.library_usable": "Suoritettavat projektiot", + "render.library_users": "Käyttäjät", + "render.light_aim": "Suuntaa kohteeseen", + "render.light_anchor_pending": "Valon koordinaatiston vaihtamista ei vielä tueta.", + "render.light_azimuth": "Atsimuutti", + "render.light_backend_limit": "Valitse CPU-säteenjäljitys tai eksplisiittinen GPU-studio valojen muokkaamiseksi. Vanhat GPU-studiovalot ovat vain luku -tilassa.", + "render.light_behaviour": "KÄYTTÄYTYMINEN", + "render.light_default_fill": "Täyttövalo", + "render.light_default_key": "Päävalo", + "render.light_default_number": "Valo {number}", + "render.light_default_rim": "Reunavalo", + "render.light_delete": "Poista", + "render.light_distance": "Etäisyys", + "render.light_duplicate": "Monista", + "render.light_elevation": "Korotuskulma", + "render.light_gpu_punctual": "Piste- ja kohdevalot ovat käytettävissä CPU-säteenjäljityksessä.", + "render.light_hard_shadow": "Tällä valolla on terävät varjot; valonlähteen pehmeys ei ole käytettävissä.", + "render.light_intensity": "Voimakkuus", + "render.light_missing": "Tätä valoa ei enää ole. Valitse valo selaimesta.", + "render.light_place_highlight": "Sijoita kohokohdan mukaan", + "render.light_place_pending": "Kohokohtasijoitus edellyttää valon valintaa näkymäikkunassa, eikä sitä vielä tueta.", + "render.light_position": "SIJAINTI", + "render.light_preview_help": "Live-näkymä on varjoton; aluevalot käyttävät keskinäytteeseen perustuvaa likiarvoa. Renderöinti käyttää täydellistä valomallia.", + "render.light_product_centre": "Tuotteen keskipiste", + "render.light_size": "Koko", + "render.light_softness": "Pehmeys", + "render.light_temperature": "Värilämpötila", + "render.light_temperature_help": "Likimääräinen mustan kappaleen sävy; tallennetaan lineaarisena RGB:nä.", + "render.light_type": "Tyyppi", + "render.light_type_area": "Aluevalo", + "render.light_type_point": "Pistevalo", + "render.light_type_spot": "Kohdevalo", + "render.light_type_sun": "Aurinko", + "render.light_view_only": "Soolo ja mykistys vaikuttavat tähän näkymään ja sen renderöinteihin; niitä ei koskaan tallenneta malliin.", + "render.light_world_origin": "Maailman origo", + "render.lighting_aim_point": "Kohdistuspiste", + "render.lighting_apply": "Käytä esiasetusta", + "render.lighting_area_samples": "Aluenäytteet", + "render.lighting_budget": "Budjetti", + "render.lighting_budget_help": "Määrät ovat vain luku -tilassa. Muuta niitä lisäämällä, monistamalla tai poistamalla valoja; menetelmä määrää rajat.", + "render.lighting_colour": "Väri", + "render.lighting_consent": "Korvaa suuntavalot tällä esiasetuksella", + "render.lighting_custom": "Mukautetut valot", + "render.lighting_custom_help": "Kun valitset Mukautettu, esiasetus muuttuu muokattaviksi valoiksi, kukin omalle rivilleen puussa.", + "render.lighting_direction": "Suunta X Y Z", + "render.lighting_direction_help": "Tai kirjoita suunta valoa kohti (X Y Z).", + "render.lighting_distance_unit": " × koko", + "render.lighting_edge_u": "Sivun U-vektori", + "render.lighting_edge_v": "Sivun V-vektori", + "render.lighting_falloff": "Vaimeneminen", + "render.lighting_falloff_help": "Voimakkuus ilmoitetaan viite-etäisyydellä.", + "render.lighting_height": "Korkeus V", + "render.lighting_minimum": "Vähimmäisarvo", + "render.lighting_mute": "Mykistä tämä valo", + "render.lighting_point": "Pistevalo", + "render.lighting_preset": "Studioesiasetus", + "render.lighting_preset_cpu": "Suuntavalojen studioesiasetukset edellyttävät CPU-säteenjäljitystä. GPU-valaistus käyttää eksplisiittisiä aurinko- ja aluevaloja.", + "render.lighting_preset_row": "Esiasetus", + "render.lighting_radiance": "Radianssi", + "render.lighting_reference": "Viite", + "render.lighting_review_help": "Korvaa suuntavalojen luettelon. Säilyttää piste- ja aluevalot, ympäristön, varjot, kameran ja kaikki muut kuvakulman asetukset.", + "render.lighting_review_title": "Tarkista valaistusesiasetus", + "render.lighting_samples_cpu": "Aluevalonäytteet koskevat CPU-säteenjäljitystä.", + "render.lighting_samples_need_area": "Lisää CPU-aluevalo, jotta voit asettaa aluenäytteet.", + "render.lighting_samples_reason": "Aluenäytteiden on oltava 1, 4, 16 tai 64.", + "render.lighting_shadow_cpu": "Varjokytkin edellyttää CPU-säteenjäljitystä. GPU-polunjäljitys heittää aina varjot.", + "render.lighting_shadows": "Heitä varjot", + "render.lighting_shadows_section": "Varjot ja näytteistys", + "render.lighting_shape": "Muoto", + "render.lighting_solo": "Soolo tälle valolle", + "render.lighting_source": "Lähde", + "render.lighting_source_section": "Valonlähde", + "render.lighting_spot_preserved": "Tämä pistevalo säilyttää tallennetun valokartionsa, kunnes sen tyyppiä muutetaan.", + "render.lighting_sun_distance": "Auringon suunta ei riipu etäisyydestä eikä kohdistuspisteestä.", + "render.lighting_unsupported": "ei tuettu", + "render.lighting_used": "{used}/{limit}", + "render.lighting_width": "Leveys U", + "render.lighting_world_position": "Maailma X Y Z", + "render.lights_fixed_to": "Valot kiinnitetty kohteeseen", + "render.linear_help": "Säilyttää 32-bittisen liukuluku-RGBA-masterin ennen kohinanpoistoa, valotusta, sävykartoitusta, suodattimia ja taustakulissia. Lineaarinen sRGB/D65; peittoalalla esikerrottu. Lisämuisti hyväksytään erikseen. Ei pakkausta eikä lisäkierroksia.", + "render.linear_retain": "Säilytä lineaarinen raaka-EXR", + "render.linear_unavailable": "Valitulla valmiilla kuvalla ei ole säilytettyä EXR:ää. Ota säilytys käyttöön ennen renderöintiä; vanhemmasta PNG:stä ei voi palauttaa raakaa radianssia.", + "render.look_add": "Lisää värisävy", + "render.look_applied": "Käytetty: {name}", + "render.look_bleach": "Bleach bypass -henkinen", + "render.look_cool": "Viileä", + "render.look_cyanotype": "Syanotypia-henkinen", + "render.look_faded": "Haalistunut vedos", + "render.look_material": "Materiaali", + "render.look_mono": "Mustavalkoinen", + "render.look_pasted": "Ulkoasu liitetty", + "render.look_sepia": "Seepia", + "render.look_strength": "Sävyn voimakkuus", + "render.look_teal_orange": "Sinivihreä / oranssi", + "render.look_title": "Luova sävypino", + "render.look_warm": "Lämmin", + "render.mapping_scale": "Fyysisen toiston kerroin", + "render.material_absorption": "Absorptio R · G · B (per mm)", + "render.material_add": "Lisää tai muokkaa tätä kappaletta", + "render.material_assignments": "Kappalemääritykset", + "render.material_binding_enable": "Sisällytä tämän kappaleen ohitus", + "render.material_box": "Akselien suuntainen laatikko", + "render.material_browse": "Valitse kuva…", + "render.material_certification": "Suljettu lasi käyttää geometrista etäisyyttä. Topologia, suunta, päällekkäisyys ja optiset rajat tarkistetaan GPU-valmistelun aikana; Tarkista vain hyväksyy pyynnön.", + "render.material_choose_body": "Valitse näkymän kappale", + "render.material_closed": "Suljettu umpinainen verkko", + "render.material_coat": "Kirkaslakka perusmateriaalin päällä", + "render.material_coat_enable": "Ota kirkaslakka käyttöön", + "render.material_coat_help": "Havainnollistava dielektrinen pinnoite olemassa olevan peittävän ulkoasun päällä. Nollapaino jättää perustan ennalleen.", + "render.material_color_map": "Perusvärikuva (sRGB)", + "render.material_color_replace": "Peruskartta korvaa kappaleen värin", + "render.material_custom": "Mukautettu havainnollistava optiikka", + "render.material_enable": "Käytä näitä GPU-materiaaliohituksia", + "render.material_filter": "Suodata näkymän kappaleita…", + "render.material_filter_help": "Enintään 128 vastaavaa kappaletta näytetään. Tarkenna suodatinta suurissa näkymissä.", + "render.material_glass": "Lasi ja optinen absorptio", + "render.material_glass_enable": "Käytä fysikaalista lasia tässä kappaleessa", + "render.material_glass_exclusive": "Lasi ei voi jakaa kappaletta kirkaslakan tai ajonaikaisten karttojen kanssa tässä renderöijässä.", + "render.material_glass_help": "Alkuperäisiä havainnollistavia reseptejä. Värimallit näyttävät läpäisyn, eivät renderöityä esikatselua. Absorptio on −ln(lineaarinen läpäisy) / viite-etäisyys; alfa ei muutu.", + "render.material_gpu_required": "Nämä aktiiviset materiaaliohitukset edellyttävät GPU-polunjäljitystä. Valitse GPU tai poista ohitukset käytöstä ennen Tarkista- tai Aloita-toimintoa.", + "render.material_ior": "Taitekerroin (IOR)", + "render.material_limits": "Enintään 128 kappaleohitusta, 32 erillistä lasikappaletta, 64 pinnoitetta ja 128 tekstuurisidontaa. Identtiset kartat jakavat tallennustilan; laitteen ja kokonaismuistin todelliset rajat ovat silti voimassa.", + "render.material_map_invalid": "Karttapolkujen on oltava rajattuja paikallisia polkuja; normaalin voimakkuuden on oltava äärellinen ja välillä 0–1.", + "render.material_map_required": "Valitse värikuva tai normaalikuva ennen karttojen käyttöä.", + "render.material_maps": "Tasomaiset väri- ja normaalikartat", + "render.material_maps_enable": "Ota kartat käyttöön tälle peittävälle kappaleelle", + "render.material_maps_help": "PNG/JPEG enintään 1024×1024; koodattuna yhteensä 16 MiB. U ja V ovat kohtisuoria maailman millimetreinä ilmaistuja toistovektoreita (enintään 128 toistoa). Väri on sRGB; normaalidata käyttää OpenGL +Y -käytäntöä. Tiedostot luetaan Tarkista/Aloita-vaiheessa.", + "render.material_metallic_map": "Metallisuuskartta (lineaarinen)", + "render.material_normal_map": "Normaalikuva (OpenGL, +Y)", + "render.material_normal_strength": "Normaalin voimakkuus (0–1)", + "render.material_origin": "Kohdistuksen origo X · Y · Z (mm)", + "render.material_refresh": "Päivitä näkymän kappaleet", + "render.material_refresh_needed": "Näkymä muuttui. Päivitä kappaleluettelo ennen kohteen lisäämistä.", + "render.material_remove": "Poista tämä määritys", + "render.material_repeat_u": "Yksi U-toisto X · Y · Z (mm)", + "render.material_repeat_v": "Yksi V-toisto X · Y · Z (mm)", + "render.material_revalidate": "Vahvista tämä kappale uudelleen", + "render.material_revalidate_needed": "Tämä määritys kuuluu aiempaan näkymäversioon. Vahvista sen tarkka kappaletunniste uudelleen ennen renderöintiä.", + "render.material_roughness": "Pinnoitteen karheus (0.045–1)", + "render.material_roughness_map": "Karheuskartta (lineaarinen)", + "render.material_shadow": "Päästä suorat varjosäteet läpi", + "render.material_target_missing": "Kappaleen järjestysnumero ja nimi eivät enää täsmää. Poista tämä määritys ja valitse aiottu kappale uudelleen erikseen.", + "render.material_thickness": "Ohuen levyn paksuus (mm)", + "render.material_thin": "Ohut levy", + "render.material_title": "GPU-kuvamateriaalit", + "render.material_transient": "Väliaikaiset optiset ohitukset tälle asiakirjavälilehdelle. Niitä ei tallenneta malliin, renderöintiasetelmaan eikä materiaalikirjastoon.", + "render.material_v_up": "Tekstuurin V osoittaa kuvassa ylöspäin", + "render.material_weight": "Pinnoitteen paino (0–1)", + "render.method_cpu": "CPU-säteenjäljitys", + "render.method_gpu": "GPU-polunjäljitys", + "render.method_limit_raster": "Rasterointi käyttää kiinteää peittävää studioesikatselua. Valitse eksplisiittinen säteenjäljitysmenetelmä tuettuja optisia säätimiä varten.", + "render.method_raster": "CPU-rasterointi", + "render.navigation_help": "Kamera liikkuu kuten Suunnittelu-tilassa (Asetukset › Navigointi). Tallenna asetelma tallentaa nykyisen kameran.", + "render.nested_enable": "Varmennetut sisäkkäiset dielektrit", + "render.nested_help": "Enintään 16 sisäkkäistä kerrosta; jokaisen vanhemman on oltava kupera ja jokaisen lapsen kokonaan sen sisällä. Leikkaukset, koskettavat pinnat, ohuet levyt ja suorien varjojen läpäisy hylätään. Kameran sisällä olon tila seuraa varmennettua hierarkiaa. Ei luo nestepintaa eikä yhdistä koskettavia CAD-rajoja.", + "render.opaque_image_summary": "Esikatselu-PNG · peittävä PBR · sekoitettu läpinäkyvyys", + "render.opaque_preview_help": "Peittävä PBR ja yksinkertaiset värit; sekoitettu läpinäkyvyys. Taittuminen, HDRI ja lopullinen renderöinti eivät ole vielä käytettävissä.", + "render.optics_remove": "Poista tallennettu optiikka", + "render.optics_replace": "Korvaa tämän kohteen tallennettu optiikka erikseen", + "render.optics_save": "Tallenna tämä optiikka asetelmaan", + "render.optics_saved": "Tallennetut optiset kohteet", + "render.optics_saved_help": "Tarkka määrittely-, piirre- ja esiintymätunniste säilyy avattaessa uudelleen offline-tilassa. Puuttuvat/korvatut/piilotetut kohteet hylätään; suunnittelumateriaalit eivät muutu. Tallenna tai hylkää numeeriset muokkaukset ennen tallennetun kohteen poistamista.", + "render.outer_half_angle": "Ulompi puolikulma", + "render.output_advanced": "Lisäasetukset", + "render.output_backend_ready": "Valmis", + "render.output_check": "Tarkista", + "render.output_check_failed": "Tarkistus · virhe", + "render.output_check_help": "Suorita Tarkista, niin voit tarkastella tätä pyyntöä aloittamatta renderöintiä.", + "render.output_check_summary": "Tarkistus · Huomioita: {count}", + "render.output_copy": "Kopioi", + "render.output_copy_report": "Kopioi raportti", + "render.output_destination": "Kohde", + "render.output_destination_help": "Valitse kohdetiedosto, kun viet valmiin kuvan.", + "render.output_dimensions": "Koko", + "render.output_effects": "Tehosteet", + "render.output_export": "Vie PNG…", + "render.output_exr_unavailable": "EXR-tulosteen asetus ei ole käytettävissä tässä. Lineaarisen EXR:n vienti edellyttää valmista GPU-kuvaa, jossa on säilytetty lineaarinen master.", + "render.output_fit": "Sovita", + "render.output_format": "Muoto", + "render.output_format_status": "PNG on täsmälleen valmis näyttökuva. Valinnainen GPU-EXR säilyttää raa'an näkymälineaarisen FLOAT32-radianssin ennen näyttösuodattimia. Vienti ei koskaan korvaa sitä esikatseluvälimuistilla tai toisella työllä.", + "render.output_grading": "Värimäärittely", + "render.output_image": "Kuva", + "render.output_largest": "enintään {width} × {height}", + "render.output_lock_ratio": "Lukitse kuvasuhde", + "render.output_over_budget": "Pyydetty kuva ylittää valitun resurssirajan. Valitse yhteensopiva koko tai profiili erikseen; automaattista heikennystä ei tehdä.", + "render.output_presets_help": "Muoto, laatu ja tarkistusluettelo ovat Tuloste-editorissa.", + "render.output_quality": "Laatu", + "render.output_quality_waiting": "odotetaan laatusopimusta", + "render.output_ratio_refused": "Linkitetty mitta ylittää tämän menetelmän kokorajan. Edellinen koko säilytetään; avaa kuvasuhteen lukitus tai valitse pienempi arvo.", + "render.output_ready": "Valmis renderöitäväksi", + "render.output_run_check": "Suorita tarkistus", + "render.output_size_limit": "{method}: enintään {dimension} px sivua kohden ja {pixels} pikseliä. Suurin sopiva koko: {width} × {height}.", + "render.output_size_ok": "Koko kunnossa · {width} × {height} px", + "render.output_swap_orientation": "Vaihda leveys ja korkeus", + "render.output_tools_inspector": "Muokkaa kokoa ja muotoa Tuloste-tarkastelijassa.", + "render.output_view_image": "Näytä kuva", + "render.page_aging": "Ikääntyminen ja lika", + "render.page_all": "Kaikki säätimet", + "render.page_appearance": "Ulkoasu", + "render.page_background": "Taustat", + "render.page_camera": "Kamera", + "render.page_diagnostics": "Diagnostiikka", + "render.page_effects": "Objektiivi- ja viimeistelytehosteet", + "render.page_environment": "Ympäristö", + "render.page_lighting": "Valaistus", + "render.page_output": "Tuloste", + "render.page_scene": "Kohtaus", + "render.paint_label": "Maalaa", + "render.painting_with": "Maalataan ulkoasulla {name}", + "render.passive_stage": "Maa ja passiivinen näyttämö", + "render.passive_stage_help": "Rajalliset passiiviset paneelit kuuluvat vain renderöityihin kuviin. Ne eivät lisää CAD-kappaleita eivätkä massaa. Sijainti on kiinteä käytön jälkeen; tuotteen rajojen kanssa leikkaavat hylätään. CPU ei tuota diffuusia heijastusvalaistusta.", + "render.perspective": "Perspektiivikamera", + "render.physical_acknowledge": "Ymmärrän, että tämä osittainen viite vaatii laadun, kunnon ja lähteiden tarkistuksen", + "render.physical_associations": "Fyysiset ehdokkaat ja roolit", + "render.physical_background_help": "Todellinen passiivinen lattia ja seinä, erillään kameran taustasta. Syklorama käyttää 24 monikulmionauhaa; se ei ole ääretön eikä analyyttisesti sileä pinta. Korvaa tallennetun näyttämön vain suostumuksellasi.", + "render.physical_copy_plan": "Kopioi suunnitelma lähteineen ja puutteineen", + "render.physical_cyclorama": "Sovita monikulmiosyklorama", + "render.physical_floor_wall": "Sovita fyysinen lattia ja seinä", + "render.physical_material_name": "Ehdotettu viitemateriaalin nimi", + "render.physical_omissions": "Ominaisuudet, joita ei voi esittää alkuperäisessä kortissa", + "render.physical_plan_only": "Vain luku -suunnitelma valmisteltu. Materiaalia ei luotu eikä määritetty. Säilytä sen laajennettu profiili ja puutteet, kun tarkistat alkuperäistä korttia.", + "render.physical_prepare_plan": "Valmistele luontisuunnitelma (ei ota käyttöön)", + "render.physical_separate": "Ulkoasu ei ole materiaalin tunnistus. Nämä ovat lähteistettyjä viitteitä, eivät sertifioituja efektiivisiä ominaisuuksia. Nykyinen massa, tekniset materiaalit ja optiikka pysyvät ennallaan.", + "render.physical_unknown": "Numeerista ehdokasta ei ole vahvistettu. Laatu, koostumus tai rakenne on määritettävä; tuntematon ei ole nolla.", + "render.pick_body": "Kappale", + "render.pick_component": "Komponentti", + "render.pick_face": "Pinta", + "render.pick_help": "Valitse kappale, pinta tai komponentti. Tyhjennä napsauttamalla tyhjää kohtaa; Shift tai Ctrl lisää tai vaihtaa valintaa. Korostukset näkyvät vain tuotteen live-näkymässä.", + "render.pick_label": "Valitse", + "render.pick_up_label": "Poimi", + "render.pick_up_prompt": "Poimi ulkoasu napsauttamalla objektia", + "render.pick_waiting": "Tuotteen valintaa ei vielä tueta.", + "render.post_contrast": "Kontrasti", + "render.post_gain_b": "Sinisen vahvistus", + "render.post_gain_g": "Vihreän vahvistus", + "render.post_gain_r": "Punaisen vahvistus", + "render.post_help": "Vain luovaan esitykseen. RGB-vahvistukset tehdään ennen sävykartoitusta; kontrasti, kylläisyys ja vinjetti sen jälkeen näytön lineaarisessa valossa. Aloita renderöinti luo uusia kuljetusnäytteitä. Uudelleensävytys käyttää valittua säilytettyä raakamasteria ilman kohinanpoistoa tai uusia säteitä.", + "render.post_reset": "Palauta esityssuodattimet", + "render.post_saturation": "Kylläisyys", + "render.post_title": "Esityssuodattimet", + "render.post_vignette": "Vinjetti", + "render.preview": "Integroitu esikatselu", + "render.preview_help": "Rasterivarjostus ja sekoitettu läpinäkyvyys. Taittuminen, HDRI ja lopullinen renderöinti eivät ole vielä käytettävissä.", + "render.preview_image_settings_help": "Live-esikatselu käyttää kiinteää studiovalaistusta. Valotus ja kuvan valaistus koskevat kuvatyötä, kun valitset Tarkista tai Aloita.", + "render.preview_lighting_help": "Live-esikatselu näyttää vain suoran valon eikä varjoja.", + "render.preview_retry": "Yritä live-esikatselua uudelleen", + "render.preview_unavailable_help": "Live-esikatselu ei ole käytettävissä. Tallennetut asetelmat ja kuvatyön säätimet ovat edelleen käytettävissä. Muuta näkymää tai yritä uudelleen erikseen; hylättyä näkymää ei rakenneta uudelleen joka kehyksessä.", + "render.preview_upload": "Live-esikatselun kärkipistedata", + "render.preview_upload_help": "Esikatselun kärkipisteiden tarkka tavumäärä, ei GPU:n tai prosessin kokonaismuistia. Geometriaa ei yksinkertaisteta. Kuvatöillä on oma hyväksyntänsä ja valaistuksensa.", + "render.progressive_image_summary": "Esikatselu-PNG · PBR ja lasi · progressiiviset näytteet", + "render.property_conditions": "Olosuhteet: {value}", + "render.property_fallback": "ominaisuus", + "render.property_qualifier": "Tarkenne: {value}", + "render.property_source": "Lähde: {id} - {locator}", + "render.property_uncertainty": "Epävarmuus: {value}", + "render.property_unknown": "tuntematon", + "render.quality_ack": "Kuittaa kiinteän budjetin projektio ja puutteet", + "render.quality_apply": "Käytä tätä kiinteää näytteistysbudjettia", + "render.quality_balanced": "Tasapainoinen", + "render.quality_custom": "Mukautettu", + "render.quality_draft": "Luonnos", + "render.quality_high": "Korkea", + "render.quality_projection_help": "Q-esiasetukset sisältävät tulevia vaatimuksia. Tämä toiminto projisoi vain tuetun kiinteänäytteisen PNG-budjetin ja jättää nimenomaisesti pois mukautuvan pysäytyksen, lineaarisen raakatulosteen ja erilliset läpäisyrajat.", + "render.quality_review": "Tarkista näytteistysbudjetti", + "render.queue_pending": "Tässä käyttöliittymässä ei ole automaattista tuotantojonoa eikä animaatiorenderöijää. Valmiit still-kuvat voi edelleen viedä erikseen.", + "render.radial_assign": "Määritä ulkoasu", + "render.radial_compare": "Vertaa", + "render.radial_distortion": "Säteittäinen vääristymä", + "render.radial_fit": "Sovita tuote", + "render.radial_save": "Tallenna kuvakulma", + "render.recent_looks": "Viimeisimmät ulkoasut", + "render.regrade_action": "Sävytä raakamaster uudelleen (ei kohinanpoistoa)", + "render.regrade_help": "Luo erillisen PNG:n valitusta valmiista raakamasterista näillä esityssäätimillä. Ei jäljitä säteitä, muuta tallennettua kokoa, kameraa tai valoja eikä korvaa EXR:ää. Kohinanpoistoa ei tehdä uudelleen: raakamasterit eivät säilytä sen apukuvia. Johdannainen on vietävissä samoilla työn säätimillä.", + "render.regrade_label": "Sävytä uudelleen", + "render.reload_settings": "Lataa tallennetut asetukset uudelleen", + "render.remove_override": "Poista ohitus", + "render.replace_all": "Korvaa kaikki", + "render.replace_ground": "Vahvista nykyisen näyttämön korvaaminen", + "render.replace_label": "Korvaa", + "render.replace_saved_surface": "Korvaa tämän kohteen olemassa oleva tallennettu sidonta", + "render.replace_with": "Korvaa tällä", + "render.restore_camera": "Palauta tallennettu kamera", + "render.result_actual": "100 %", + "render.result_actual_help": "Yksi kuvan pikseli näytön pikseliä kohden.", + "render.result_actual_size": "100 %", + "render.result_capture": "Tallennettu revisio", + "render.result_current": "Tallennettu mallin revisio on ajan tasalla.", + "render.result_empty": "Valmista kuvaa ei vielä ole. Tarkista asetukset ja aloita sitten renderöinti.", + "render.result_fit": "Sovita kuva", + "render.result_job": "Työ", + "render.result_navigation": "Panorointi ja zoomaus noudattavat navigointiasetuksia. Välilyönti + veto panoroi.", + "render.result_open": "Avaa valmis kuva pääpiirtoalueelle.", + "render.result_preview": "Live-esikatselu", + "render.result_provisional": "Väliaikainen CPU-kuva", + "render.result_provisional_help": "Tätä välitallennetta ei voi viedä. Vienti säilyttää viimeisimmän valmiin tuloksen; peruutus palaa siihen tulokseen.", + "render.result_reduced_display": "Näytön esikatselu pienennetty näkymäikkunan laitteen/välimuistin rajaan. Säilytetty PNG ja vienti säilyttävät täyden resoluution; alkuperäinen 100 %:n tarkastelu ei ole käytettävissä tässä pienennetyssä näkymässä.", + "render.result_retained": "Näytetään viimeisin valmis kuva; uudempi renderöinti ei ole korvannut sitä.", + "render.result_sequence": "Välitallenne", + "render.result_title": "Renderöinnin tulos", + "render.return_design": "Palaa suunnitteluun", + "render.review_stale": "Tämä tarkistus on vanhentunut. Ratkaise tai tarkista uudelleen ennen käyttöönottoa.", + "render.ribbon_aging": "Vanhentaminen", + "render.ribbon_backgrounds": "Taustat", + "render.ribbon_check": "Tarkista", + "render.ribbon_check_help": "Tarkista täsmälleen se nykyinen luonnos, jonka Renderöi lähettää. Ei varalla olevaa renderöijää, hiljaista koon muutosta tai resurssien korvaamista.", + "render.ribbon_edit_lights": "Muokkaa valoja", + "render.ribbon_effects": "Tehosteet", + "render.ribbon_environment_help": "HDR-valaistus, vain kameralle näkyvät taustat ja fyysiset näyttämöpinnat ovat erillisiä säätimiä, joiden korvaaminen vaatii nimenomaisen suostumuksen.", + "render.ribbon_finish_dialog": "Viimeistele ensin nykyinen tiedosto- tai vahvistusikkuna.", + "render.ribbon_frame": "Rajaa", + "render.ribbon_hdr_library": "HDR-kirjasto", + "render.ribbon_job_active": "Renderöinti on jo käynnissä; peruuta se erikseen ennen uuden aloittamista.", + "render.ribbon_light_rigs": "Valoasetelmat", + "render.ribbon_lights_help": "Tarkastele laadittuja valoasetelmia tai muokkaa tuettuja valoja korvaamatta nykyistä asetelmaa, kunnes otat muutoksen erikseen käyttöön.", + "render.ribbon_materials": "Materiaalit", + "render.ribbon_materials_help": "Avaa jaettu ulkoasutarkastin ja alkuperäinen pintakirjasto. Optiset muokkaukset eivät koskaan muuta teknisiä ominaisuuksia huomaamatta.", + "render.ribbon_no_active_job": "Peruutettavaa aktiivista renderöintiä ei ole.", + "render.ribbon_no_result": "Viimeistele renderöinti ennen sen tarkan kuvan vientiä.", + "render.ribbon_no_saved_camera": "Tallenna kuvakulma ennen sen kameran palauttamista.", + "render.ribbon_saved_camera": "Tallennettu kamera", + "render.ribbon_shots": "Kuvakulmat", + "render.ribbon_studios": "Studiot", + "render.ribbon_studios_help": "Selaa toteutettuja studiokoostumuksia ja tarkista niiden korvaukset ennen käyttöönottoa. Pelkät konseptinäkymät pysyvät merkittyinä.", + "render.ribbon_surface_maps": "Pintakartat", + "render.sampling_title": "Mukautuva ja läpäisynäytteistys", + "render.save_continue": "Tallenna ja jatka", + "render.save_setup": "Tallenna asetelma", + "render.save_shot": "Tallenna kuvakulma", + "render.saved_maps_runtime_conflict": "Tallennettuja pintasidontoja ei voi yhdistää väliaikaisiin optisiin ohituksiin tai tallentamattomaan HDR:ään. Tallenna HDR tai poista väliaikaiset syötteet erikseen.", + "render.saved_surface_remove": "Poista tallennettu sidonta", + "render.saved_surfaces": "Tallennetut pintasidonnat", + "render.saved_surfaces_help": "Kiinnitetyt sisäiset kartat seuraavat valittua esiintymää paikallisissa millimetreissä. Korvattu tai puuttuva kohde on virhe, ei koskaan automaattinen uudelleenmääritys.", + "render.scene_all_shots": "Kaikki kuvakulmat ({count})", + "render.scene_apply": "Ota tarkistettu studio käyttöön", + "render.scene_batch_reason": "Kaikki kuvakulmat vaatii tallennettujen kuvakulmien renderöintijonon, joka ei ole vielä käytettävissä.", + "render.scene_check_now": "Tarkista nyt", + "render.scene_check_passed": "Tarkistus läpäisty · {notes} huomautusta", + "render.scene_check_stale": "Tarkistus vanhentunut · tarkista uudelleen", + "render.scene_check_unchecked": "Ei vielä tarkistettu · 0 huomautusta", + "render.scene_consent": "Vahvista valaistuksen, ympäristön, valotuksen ja näyttämön korvaaminen", + "render.scene_light_summary": "{environment} · {lights} valoa", + "render.scene_look_summary": "{looks} ulkoasua · {overrides} ohitusta", + "render.scene_open_check": "Avaa tarkistus", + "render.scene_preserved_fields": "Kamera, näytteistys, mitat, optiset sidonnat, CAD-geometria ja tekniset materiaalit säilytetään.", + "render.scene_preserves_design": "Sommittele valaistus ja passiivinen näyttämö näkyvälle kohteelle. Geometria, tekniset materiaalit ja kamera pysyvät erillään.", + "render.scene_ready": "Valmis renderöitäväksi", + "render.scene_reminder": "Näkymä säilyttää mallin. Geometriaa, teknisiä ominaisuuksia tai CAD:ia ei koskaan muuteta renderöinnistä käsin.", + "render.scene_reminders": "Muistutukset", + "render.scene_renderer_ready": "{renderer} valmis", + "render.scene_replaces": "Korvaa", + "render.scene_review": "Ratkaise ja tarkista", + "render.scene_review_help": "Tarkista koko alkuperäinen koostumus ennen käyttöönottoa. Sitomattomia edistyneitä SCENE-määrityksiä ei korvata.", + "render.scene_scope_details": "Koostumuksen laajuus", + "render.scene_stages": "Näyttämöt", + "render.search_appearances": "Hae {count} ulkoasusta", + "render.setup_legacy_environment": "Tämä vanhempi asetelma käyttää vanhaa ympäristön kiertoa tai voimakkuutta. Arvot säilytetään; muunna ne erikseen ennen täyden kuvatyön tallentamista.", + "render.setup_name": "Asetelman nimi", + "render.setup_numeric_help": "Tallennus kirjaa aktiivisen kuvamenetelmän ja kaikki sen numeeriset asetukset. Uudelleenlataus tai toisen asetelman valinta korvaa tämän luonnoksen; palauta kamera erikseen.", + "render.setup_offline_help": "Voit muokata ja tallentaa tämän menetelmän ilman sen renderöijää. Tarkista ja Aloita vaativat käytettävissä olevan isännän.", + "render.setup_runtime_unsupported": "Tätä täyttä työtä ei voi vielä tallentaa: se sisältää karttoja tai näkymän kappalesidontoja. Poista nämä väliaikaiset syötteet ennen siirrettävän asetelman tallentamista.", + "render.setup_save_missing": "Tallennettua renderöintikonfiguraatiota ei löytynyt komennon suorituksen jälkeen.", + "render.sharpening": "Yksityiskohtien terävöinti", + "render.shell_tab_appearance": "RENDERÖINNIN ULKOASU", + "render.shell_tab_camera": "RENDERÖINNIN KAMERA", + "render.shell_tab_lighting": "VALAISTUS", + "render.shell_tab_scene": "RENDERÖINNIN NÄKYMÄ", + "render.shot_aperture": "Aukko", + "render.shot_distance": "Etäisyys", + "render.shot_dof": "Syväterävyys", + "render.shot_duplicate": "Monista tallennettu kuvakulma", + "render.shot_edit_output": "Muokkaa tulosteessa", + "render.shot_exposure_reason": "Valotus vaatii CPU-säteenjäljityksen tai GPU-polunjäljityksen.", + "render.shot_exposure_section": "Valotus", + "render.shot_focus_distance": "Tarkennusetäisyys", + "render.shot_fov_reason": "Näkökenttä vaatii perspektiiviprojektion.", + "render.shot_frame_subject": "Rajaa kohde", + "render.shot_invalid_camera": "Kameran arvojen on oltava äärellisiä ja sallitulla alueella.", + "render.shot_lens": "Objektiivi", + "render.shot_lens_enable_reason": "Ota syväterävyys käyttöön muokataksesi aukkoa ja tarkennusetäisyyttä.", + "render.shot_lens_gpu_reason": "Syväterävyys vaatii GPU-polunjäljityksen.", + "render.shot_lens_perspective_reason": "Syväterävyys vaatii perspektiiviprojektion.", + "render.shot_look_selection": "Katso valintaa kohti", + "render.shot_manager": "Tallennetut kuvakulmat", + "render.shot_new_name": "Uuden kuvakulman nimi", + "render.shot_remove": "Poista tallennettu kuvakulma", + "render.shot_remove_help": "Poista vain tämä tallennettu asetelma. Lähdegeometria ja valmiit kuvat säilytetään; toiminnon voi kumota.", + "render.shot_remove_title": "Poistetaanko tallennettu kuvakulma?", + "render.shot_rename": "Nimeä tallennettu kuvakulma uudelleen", + "render.shot_saved_only": "Tallenna nykyinen luonnos ensin. Monistus ja uudelleennimeäminen säilyttävät tallennetut asetukset ja resurssiviittaukset täsmälleen.", + "render.shot_selection_reason": "Valitse kappale tai pinta, johon tähdätään.", + "render.shot_size": "Koko", + "render.shot_target": "Kohde", + "render.shots_camera_changed": "Kamera muuttunut kuvakulman tallennuksen jälkeen", + "render.shots_name_help": "Anna nykyiselle kuvakulmalle yksilöllinen nimi.", + "render.shots_no_result": "Ei vielä renderöintiä", + "render.shots_result": "Renderöinti {number} · {width} × {height}", + "render.shots_revert": "Palauta", + "render.shots_save": "Tallenna kuvakulma", + "render.shots_update": "Päivitä kuvakulma", + "render.show_shots_label": "Näytä kuvakulmanauha", + "render.size_4k": "4K", + "render.size_4k_uhd": "4K UHD", + "render.size_custom": "Mukautettu…", + "render.size_full_hd": "Full HD", + "render.size_hd": "HD", + "render.size_presets": "Kokoesiasetukset", + "render.size_presets_pending": "Tulee Tuloste-editorin mukana", + "render.size_preview": "Esikatselu", + "render.size_square": "Neliö", + "render.sky_ground": "Maa · lineaarinen RGB", + "render.sky_horizon": "Horisontti · lineaarinen RGB", + "render.sky_zenith": "Zeniitti · lineaarinen RGB", + "render.soft_focus": "Pehmeä tarkennus", + "render.spot_cone": "Spottivalon kartio", + "render.stage_frame": "Rajaa", + "render.stage_layout.appearance_note": "Ota käyttöön valintaan napsauttamalla.", + "render.stage_layout.studio_note": "Napsauta tarkistaaksesi ennen käyttöönottoa.", + "render.stage_light": "Valo", + "render.stage_look": "Ulkoasu", + "render.stage_orthographic": "Ortografinen", + "render.stage_output": "Tuloste", + "render.stage_panel_count": "Tallennetut paneelit", + "render.stage_panels.acknowledge": "Kuittaan korvaamisen ja rajoitukset", + "render.stage_panels.appearance_note": "Ota käyttöön valintaan napsauttamalla. Vedä malliin. Kun mitään ei ole valittu, napsautus aktivoi maalauksen.", + "render.stage_panels.apply": "Käytä", + "render.stage_panels.apply_studio": "Ota studio käyttöön", + "render.stage_panels.badge_blocked": "estetty", + "render.stage_panels.badge_native": "alkuperäinen", + "render.stage_panels.badge_projection": "projektio", + "render.stage_panels.badge_reference": "viite", + "render.stage_panels.badge_specification": "vain määritys", + "render.stage_panels.candidate_summary": "Ehdokkaat: {count} lähteistettyä arvoa. Laatu ja viimeistely vaativat tarkistuksen.", + "render.stage_panels.captured_bounds": "Käytä tallennettuja kohtauksen rajoja", + "render.stage_panels.centre": "Keskipiste", + "render.stage_panels.depth": "syvyys", + "render.stage_panels.environments": "Ympäristöt", + "render.stage_panels.extent": "Laajuus", + "render.stage_panels.fit_save": "Sovita ja tallenna", + "render.stage_panels.gpu_required": "Vaatii GPU-polunjäljityksen", + "render.stage_panels.keep": "Säilytä", + "render.stage_panels.kept": "Säilytetään", + "render.stage_panels.kept_fields": "Kamera, näytteistys, mitat, optiset sidonnat, CAD-geometria ja tekniset ominaisuudet.", + "render.stage_panels.kept_quality": "Menetelmä, kamera, valaistus, ympäristö, näyttämö, optiikka, valotus ja sävykartoitus.", + "render.stage_panels.mine": "Omat", + "render.stage_panels.mine_pending": "Omien studioiden tallentamista ei vielä tueta.", + "render.stage_panels.omits": "Jättää pois", + "render.stage_panels.paint_pending": "Maalausta ja vetämällä määrittämistä ei vielä tueta. Valitse kohde ulkoasun käyttöönottoa varten.", + "render.stage_panels.partial_reference": "Ehdokkaat: vain lähteistettyjä arvoja; tarkista laatu, kunto ja lähteet. Tuntemattomat arvot pysyvät tuntemattomina.", + "render.stage_panels.plane": "Taso", + "render.stage_panels.prepare_plan": "Valmistele luontisuunnitelma", + "render.stage_panels.projects": "Projektit", + "render.stage_panels.quality_ack": "Kuittaan kiinteän budjetin ja sen puutteet", + "render.stage_panels.quality_omissions": "Mukautuva pysäytys ja virhetavoitteet, erillinen läpäisysyvyys, venäläisen ruletin säännöt, lineaarinen raakatuloste, EXR-master ja konvergenssin sertifiointi.", + "render.stage_panels.reference": "Viite", + "render.stage_panels.references": "Viitteet", + "render.stage_panels.remove_optics": "Poista optiikka", + "render.stage_panels.remove_optics_help": "Poistaa tallennetun optiikan tästä kuvakulmasta. Lähdegeometria säilytetään.", + "render.stage_panels.remove_optics_title": "Poistetaanko tallennettu optiikka?", + "render.stage_panels.remove_shot": "Poista kuvakulma", + "render.stage_panels.remove_stage": "Poista näyttämö", + "render.stage_panels.remove_stage_help": "Poistaa tallennetut passiiviset paneelit tästä kuvakulmasta. Kameran tausta säilyy.", + "render.stage_panels.remove_stage_title": "Poistetaanko tallennettu näyttämö?", + "render.stage_panels.repeat": "Toista", + "render.stage_panels.replaces": "Korvaa", + "render.stage_panels.replaces_environment": "Ympäristö", + "render.stage_panels.replaces_maps": "Väliaikaiset kartat valitussa kappaleessa; tallennettu optiikka voi olla ristiriidassa.", + "render.stage_panels.replaces_physical": "Ei mitään. Tämä suunnitelma on vain luku -tilassa.", + "render.stage_panels.replaces_quality": "Kuvan mitat, kiinteät näytteet, polkusyvyys, siemen ja kohinanpoisto.", + "render.stage_panels.replaces_rig": "Valaistus, ympäristö ja näyttämö", + "render.stage_panels.replaces_saved_maps": "Tallennetut pintakartat valitussa kappaleessa; väliaikaiset ohitukset voivat olla ristiriidassa.", + "render.stage_panels.replaces_stage": "Tallennettu maa ja passiiviset näyttämöpaneelit", + "render.stage_panels.replaces_studio": "Valaistus, ympäristö, valotus ja näyttämö", + "render.stage_panels.replaces_uniform": "Läpinäkymätön ulkoasu valitussa kappaleessa. Spatiaaliset kartat arvioidaan tasaisilla arvoilla.", + "render.stage_panels.review_environment": "Tarkista ympäristö", + "render.stage_panels.review_maps": "Tarkista GPU-kartat", + "render.stage_panels.review_physical": "Fyysinen viitesuunnitelma", + "render.stage_panels.review_quality": "Käytä kiinteää näytteistysbudjettia", + "render.stage_panels.review_rig": "Sovita valoasetelma", + "render.stage_panels.review_saved_maps": "Tarkista tallennetut kartat", + "render.stage_panels.review_stage": "Tarkista maa ja näyttämö", + "render.stage_panels.review_studio": "Tarkista alkuperäinen studio", + "render.stage_panels.review_uniform": "Tarkista tasainen ulkoasu", + "render.stage_panels.rig_summary": "Laadittu valoasetelma · {emitters} säteilijää, {cards} korttia", + "render.stage_panels.rigs": "Valoasetelmat", + "render.stage_panels.save_maps": "Tallenna kartat tähän kuvakulmaan", + "render.stage_panels.scope": "Laajuus", + "render.stage_panels.search_studios": "Hae studioita", + "render.stage_panels.search_surfaces": "Hae pintoja ja viitteitä", + "render.stage_panels.source_note": "Laadittu perusvärin lähdekartta. Ei mitattu materiaali.", + "render.stage_panels.specifications": "Määritykset · vain luku", + "render.stage_panels.studio_ack": "Vahvista valaistuksen, ympäristön, valotuksen ja näyttämön korvaaminen", + "render.stage_panels.studio_note": "Napsauta tarkistaaksesi. Käyttöönotto korvaa valaistuksen, ympäristön ja näyttämön vasta vahvistuksesi jälkeen. Valot pysyvät muokattavina.", + "render.stage_panels.studios": "Studiot", + "render.stage_panels.subtitle_environment": "Alkuperäinen HDR-ympäristö · koko näkymä", + "render.stage_panels.subtitle_maps": "Laaditut spatiaaliset kartat · väliaikainen esikatselu", + "render.stage_panels.subtitle_physical": "Vain luku -suunnitelma · materiaalia ei luotu eikä määritetty", + "render.stage_panels.subtitle_quality": "Kiinteä GPU-budjetti · tulevat vaatimukset jätetty pois", + "render.stage_panels.subtitle_rig": "Laadittu valoasetelma · säteilijät ja passiiviset kortit", + "render.stage_panels.subtitle_saved_maps": "Laaditut kartat · tallennettu tämän kuvakulman kanssa", + "render.stage_panels.subtitle_stage": "Fyysinen maa ja näyttämö · sovitettu tuotteeseen", + "render.stage_panels.subtitle_studio": "Alkuperäinen studio · koskee koko näkymää", + "render.stage_panels.subtitle_uniform": "Tasainen likiarvo · ei mitattu materiaali", + "render.stage_panels.surfaces": "Pinnat", + "render.stage_panels.surfaces_references": "Pinnat ja viitteet", + "render.stage_panels.target": "Kohde", + "render.stage_panels.use_budget": "Käytä tätä budjettia", + "render.stage_panels.use_maps": "Käytä GPU-karttoja", + "render.stage_panels.use_uniform": "Käytä tasaista", + "render.stage_preview_note": "Integroitu esikatselu · suora valo", + "render.state_completed": "Valmis kuva", + "render.state_in_progress": "Kuva työn alla", + "render.state_model": "Mallin esikatselu", + "render.strip_label": "Nauha", + "render.studio_add_area": "Lisää aluevalo", + "render.studio_ambient": "Vakio ympäristön radianssi", + "render.studio_ambient_rgb": "Vakio ympäristön radianssi (lineaarinen RGB)", + "render.studio_aperture": "Aukon säde", + "render.studio_area": "Aluevalo", + "render.studio_area_help": "Enintään neljä yksipuolista suorakulmiota. Kohtisuorat puolisärmät U ja V määrittävät koon; U × V osoittaa valaistulle puolelle. Koordinaatit ovat maailman millimetreinä.", + "render.studio_center": "Keskipiste X, Y, Z (mm)", + "render.studio_denoise": "Vähennä kohinaa valmiissa kuvassa", + "render.studio_denoise_help": "Lopullinen reunat huomioiva suodatin; hienot yksityiskohdat voivat pehmentyä. Lasi ja terävät peilipinnat ohittavat suodattimen.", + "render.studio_direction": "Suunta aurinkoa kohti (X, Y, Z)", + "render.studio_environment": "Ympäristö", + "render.studio_environment_bounds": "Ympäristön kierron on oltava äärellinen välillä ±1 000 000 radiaania ja voimakkuuden välillä 0–100.", + "render.studio_environment_none": "Ei ympäristökarttaa", + "render.studio_focus": "Aksiaalinen tarkennusetäisyys", + "render.studio_half_u": "Puolisärmä U (mm)", + "render.studio_half_v": "Puolisärmä V (mm)", + "render.studio_hdr": "Radiance HDR -tiedosto", + "render.studio_hdr_browse": "Valitse HDR…", + "render.studio_hdr_help": "Radiance .hdr, 2:1-panoraama, enintään 2048 × 1024. EXR ei ole käytettävissä. Väri-oletus koskee vain tapauksia, joissa pääväriä ei ole määritetty.", + "render.studio_hdr_required": "Valitse paikallinen .hdr-tiedosto.", + "render.studio_hdr_srgb": "Tulkitse määrittämättömät päävärit lineaariseksi sRGB:ksi", + "render.studio_intensity": "Ympäristön voimakkuus", + "render.studio_lens": "Syväterävyys", + "render.studio_lens_perspective": "Syväterävyys vaatii perspektiivikameran.", + "render.studio_lighting": "Studiovalaistus", + "render.studio_lighting_enable": "Käytä mukautettua studiovalaistusta", + "render.studio_needs_review": "Tarkista menetelmä ja asetukset", + "render.studio_radiance": "Lineaarinen RGB-radianssi", + "render.studio_ready": "Valmis tarkistettavaksi", + "render.studio_remove_area": "Poista aluevalo", + "render.studio_rotation": "Ympäristön kierto", + "render.studio_runtime_help": "Numeeriset studioasetukset ja tarkistetut HDR-lähteet voi tallentaa asetelman mukana. Kappaleiden optiset/materiaaliohitukset pysyvät väliaikaisina eivätkä muuta tallennettuja ulkoasuja.", + "render.studio_saved": "Tallennettu asetelma / nykyinen kamera", + "render.studio_sky": "Taiteellinen taivas", + "render.studio_sky_help": "Alkuperäinen taivasliukuväri valaistukseen; ei kalibroitu ilmakehämalli.", + "render.studio_sun": "Aurinko", + "render.studio_sun_radius": "Kulmasäde", + "render.studio_tiles_require_gpu": "Studiot vaativat GPU-polunjäljityksen. Valitse GPU Tuloste-editorissa.", + "render.studio_title": "Renderöintistudio", + "render.studio_tone_map": "Sävykartoitus", + "render.studios_environments": "Studiot ja ympäristöt", + "render.subsurface_enable": "Sisäinen sironnan satunnaiskulku", + "render.subsurface_help": "Vain suljetut dielektriset kappaleet. Sironta ja RGB-absorptio ovat erillisiä mm-kohtaisia kertoimia. Polut siroavat todellisen geometrian sisällä ja taittuvat tai heijastuvat sitten rajapinnalla. Poista käytöstä suora varjojen läpäisy, pintojen kohinanpoisto ja ulkoinen tilavuus; käytä yhtä Fresnel-kerrosta. Ei diffuusiovarjostin, tiheysruudukko eikä mitattu materiaali. Tiheät väliaineet voivat vaatia enemmän näytteitä ja polkusyvyyttä.", + "render.subsurface_rate": "Sironnan törmäystaajuus", + "render.tone_aces_fitted": "ACES fitted", + "render.tone_reinhard": "Reinhard", + "render.transmission_scope": "GPU-lasi tukee sertifioituja erillisiä väliaineita. Rajoittamattomia sisäkkäisiä väliaineita ja kohinatonta läpäisyestimaattoria ei tarjota.", + "render.transmission_strata": "Ehdolliset Fresnel-kerrokset", + "render.transmission_strata_help": "1 säilyttää alkuperäisen virran. 2 tai 4 alipolkua kerrostavat ensimmäisen lasin heijastus-/läpäisyvalinnan muuttamatta Snellin ja absorption lakeja. Useampi polku vaatii enemmän työtä; tämä ei ole kohinanpoisto, sisäkkäisten väliaineiden tuki eikä lupaus nopeammasta renderöinnistä.", + "render.tree_add_light": "Lisää valo", + "render.tree_add_shot": "Lisää kuvakulma", + "render.tree_browser": "Renderöintiselain", + "render.tree_by_look": "Ulkoasun mukaan", + "render.tree_by_object": "Objektin mukaan", + "render.tree_constant": "Vakioympäristö", + "render.tree_default": "Oletus", + "render.tree_directional": "Suuntavalo", + "render.tree_environment": "Ympäristö", + "render.tree_environment_values": "{rotation}° · {intensity}×", + "render.tree_face_count": "{count} pintaa", + "render.tree_face_one": "{count} pinta", + "render.tree_fixed_studio": "Kiinteä studio", + "render.tree_hdr": "HDR", + "render.tree_lights": "Valot", + "render.tree_lights_pending": "Nimettyjen valojen komennot eivät ole vielä käytettävissä.", + "render.tree_mixed": "Sekalainen", + "render.tree_more": "{count} lisää", + "render.tree_mute": "Mykistä valo", + "render.tree_no_environment": "Ei ympäristöä", + "render.tree_objects": "Objektit", + "render.tree_output": "Tuloste", + "render.tree_point": "Pistevalo", + "render.tree_rectangle": "Suorakulmio", + "render.tree_search": "Hae objekteja", + "render.tree_selection": "Renderöinnin valinta", + "render.tree_shots": "Kuvakulmat", + "render.tree_sky": "Taivas", + "render.tree_solo": "Vain tämä valo", + "render.tree_spot": "Kohdevalo", + "render.tree_sun": "Aurinko", + "render.tree_switch": "Vaihda", + "render.tree_user_one": "{count} objekti", + "render.tree_users": "{count} objektia", + "render.unavailable_preview": "Tämä asetelma pyytää esikatseluprofiilia, joka ei ole käytettävissä.", + "render.unsaved": "Tallentamattomia esikatselumuutoksia", + "render.use_preview": "Käytä integroitua esikatselua", + "render.volume_albedo": "Sironta-albedo · lineaarinen RGB", + "render.volume_anisotropy": "Vaihefunktion keskikosini", + "render.volume_enable": "Ota osallistuva väliaine käyttöön", + "render.volume_extinction": "Ekstinktiokerroin", + "render.volume_help": "GPU-kuljetus yhden rajallisen laatikon sisällä Beerin vaimennuksella ja HG-sironnalla kamera- ja epäsuorilla poluilla. Käyttää nimenomaista studio-/HDR-/taivasvalaistusta, myös varjojen vaimennusta. Positiivinen vaihekosini sirottaa eteenpäin. Lasirajapinnat ja pintojen kohinanpoisto on poistettava käytöstä erikseen. Nolla-ekstinktio jättää kuljetuksen ennalleen. Ei fyysinen kuun ilmakehä, tiheysruudukko, tilavuussäteily, spektrikuljetus eikä sertifioitu materiaaliominaisuus.", + "render.volume_max": "Enimmäisrajat · maailman mm", + "render.volume_min": "Vähimmäisrajat · maailman mm", + "render.volume_title": "Rajattu homogeeninen tilavuus", + "render.wait_add_light": "ei vielä tuettu: nimettyjen valojen luonti", + "render.wait_batch": "ei vielä tuettu: tallennettujen kuvakulmien renderöintijono", + "render.wait_compare": "ei vielä tuettu: säilytettyjen kuvien vertailu", + "render.wait_paint": "ei vielä tuettu: tuotteen valinta ja ulkoasun maalaus", + "render.wait_pick_up": "ei vielä tuettu: tuotteen valinta ja ulkoasun näytteistys", + "render.wait_replace": "ei vielä tuettu: ulkoasun kaikkien käyttökertojen korvaaminen", + "render.wait_solo": "ei vielä tuettu: valojen näkyvyys- ja soolosäätimet", + "render.wear_applies_to": "Koskee", + "render.wear_layer_count": "{count} kerrosta", + "render.wear_layer_one": "{count} kerros", + "render.wear_layers": "Kerrokset", + "render.wear_no_layers": "Ei kerroksia", + "render.wear_selected_layer": "Valittu kerros", + "render.wear_title": "Kuluminen", + "sample.bracket.about": "Pohjalaippa, jossa reunalaipat ja huulos 2 mm:n säännöllä", + "sample.bracket.name": "Ohutlevykiinnike", + "sample.enclosure.about": "Kuorittu laatikko, jossa huuli, ruuvitornit, tuki ja napsautusliitos", + "sample.enclosure.name": "Muovikotelo", + "sample.flange.about": "Napa ja laippa, jossa parametrilla ohjattu ympyräkuvio", + "sample.flange.name": "Pultattu laippa", + "sample.hinge.about": "Kaksi komponenttia ja kiertonivel, ohjattu 60°:seen", + "sample.hinge.name": "Saranakokoonpano", + "sample.plate.about": "Parametrinen levy, jossa pyöristykset, reiät ja porattu tappi", + "sample.plate.name": "Esimerkkilevy", + "sample.vents.about": "Urat suorakulmaisessa kuviossa: rivit, sarakkeet ja jako ovat parametreja", + "sample.vents.name": "Tuuletuslevy", + "search.command_keys": "Enter suorita Shift+Enter suorita ja pidä auki Esc sulje", + "search.commands": "Komennot", + "search.design": "Tässä mallissa", + "search.feature": "Piirre", + "search.invalid": "Ei kelvollinen", + "search.keys": "↑ ↓ valitse Enter suorita Tab täydennä nimi Esc sulje", + "search.no_match": "Mikään komento ei vastaa hakua '{query}'", + "search.parameter": "Parametri", + "search.recent": "Viimeisimmät", + "search.run": "Suorita komento", + "search.suggest": "Tarkoititko {label}?", + "search.try": "Kirjoita komennon nimi, tämän mallin piirre tai parametri. Jos haluat suorittaa komennon arvoilla, kirjoita sen tunnus ja JSON-olio: {example}", + "search.valid": "Kelvollinen", + "section.off_hint": "Tasoa ei valittu: OK poistaa leikkauksen käytöstä.", + "select a direction (an axis, a sketch line or a straight edge) first": "valitse ensin suunta (akseli, luonnosviiva tai suora särmä)", + "select a direction (an axis, a sketch line or a straight edge) for the profile to follow": "valitse suunta (akseli, luonnosviiva tai suora särmä), jota profiili seuraa", + "select a point on a component": "valitse piste komponentista", + "select a profile, edges or sketch lines first": "valitse ensin profiili, särmät tai luonnosviivat", + "select an axis": "valitse akseli", + "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "valitse ensin akseli, suora tai ympyränmuotoinen särmä tai sylinteri-, kartio- tai toruspinta", + "select bodies first": "valitse ensin kappaleet", + "select components first": "valitse ensin komponentit", + "select corner points, or the two lines of one corner": "valitse kulmapisteet tai yhden kulman kaksi viivaa", + "select one corner point, or the two lines of one corner": "valitse yksi kulmapiste tai yhden kulman kaksi viivaa", + "select sheet edges first": "valitse ensin levyn särmät", + "select the bend line (a sketch line) first": "valitse ensin taivutusviiva (luonnosviiva)", + "select the cam's profile (a sketch profile or the cam's edges)": "valitse nokan profiili (luonnosprofiili tai nokan särmät)", + "select the corner to cut first": "valitse ensin leikattava kulma", + "select the corners to round first": "valitse ensin pyöristettävät kulmat", + "select the fixed face first (a plate's large face)": "valitse ensin kiinteä pinta (levyn suuri pinta)", + "select the frame sketch's lines first": "valitse ensin runkoluonnoksen viivat", + "select the path (sketch lines across the edge)": "valitse polku (särmän yli kulkevat luonnosviivat)", + "select the rim face first": "valitse ensin reunapinta", + "select the sheet edge first": "valitse ensin levyn särmä", + "select two components": "valitse kaksi komponenttia", + "set by the sketch's lines": "luonnoksen viivojen määräämä", + "settings.display_quality_note": "Vain näyttö. Malleihin ja vienteihin tämä ei vaikuta.", + "settings.double_click_components": "Aktivoi komponentti kaksoisnapsauttamalla tai muokkaa linkitettyä komponenttia paikallaan", + "settings.frame_rate_details_note": "Lisää kaavion viimeisimpien kehysten ajoista ja niiden päälle piirrettyjen kolmioiden määrästä, kehysaikojen histogrammin sekä tiedot siitä, mitä GPU piirsi. Näytä tai piilota ne napsauttamalla näkymän kehysnopeutta.", + "settings.frame_rate_note": "Näyttää kehysnopeuden (kehystä sekunnissa) näkymän oikeassa alakulmassa.", + "settings.insert_lists_open_designs": "Lisää malli -luettelo näyttää ensin muissa välilehdissä avoinna olevat mallit", + "settings.measure_axis_distances": "Näytä X/Y/Z-etäisyydet", + "settings.measure_axis_distances_help": "Näytä maailman akselien suuntaiset komponenttimitat kokonaisetäisyyden rinnalla, kun Mittaa on auki.", + "settings.msaa_applies_on_restart": "Reunanpehmennys tulee voimaan, kun SolidCraft käynnistetään seuraavan kerran", + "settings.msaa_reset": "{setting}: {requested}× ei ole tämän näytönohjaimen tukema; asetus on nyt {reset}× (tuetut: {supported}).", + "settings.msaa_unsupported": "Tämä näytönohjain ei tue", + "settings.reference_geometry_through_bodies": "Näytä apugeometria kappaleiden läpi", + "settings.reference_geometry_through_bodies_help": "Piirrä passiivisten luonnosten ääriviivat ja aputasot varjostettujen kappaleiden päälle", + "settings.reset_intro": "Nämä asetukset eivät toimi tällä tietokoneella ja estäisivät SolidCraftin käynnistymisen, joten niitä muutettiin:", + "settings.reset_title": "Asetuksia muutettiin", + "settings.supersampling_note": "Piirtää näkymän suuremmalla resoluutiolla ja suodattaa sen alas: tasaisemmat särmät, viivat ja ruudukko alle 4K-näytöillä. Se kuluttaa GPU-aikaa: noin 2,25× pikseleitä kertoimella 1,5× ja 4× kertoimella 2×.", + "settings.update_changed_links": "Päivitä muuttuneet linkitetyt mallit, kun välilehdelle palataan", + "settings.window_system_details": "Tiedot", + "settings.window_system_note_now": "Tulee voimaan uudelleenkäynnistyksen jälkeen. Nyt: {now}.", + "settings.window_system_wayland_limited": "Osa ominaisuuksista on rajoitettu natiivissa Waylandissa.", + "sheet.flange_type_base": "Pohja", + "sheet.flange_type_contour": "Ääriviiva", + "sheet.flange_type_edge": "Särmä", + "sheet.no_body": "ohutlevykappaletta ei ole: aloita sellainen komennolla Laippa > Pohja tai muuta levy ohutlevyksi komennolla Muunna ohutlevyksi (valitse sen suuri tasopinta)", + "sheet.personal_rules.confirm_delete": "Vahvista poisto", + "sheet.personal_rules.delete": "Poista", + "sheet.personal_rules.delete_prompt": "Poistetaanko tämä henkilökohtainen sääntö? Malleissa jo olevat säännöt eivät muutu.", + "sheet.personal_rules.full": "Henkilökohtaisessa kirjastossa on jo 64 sääntöä.", + "sheet.personal_rules.invalid_name": "Anna lyhyt nimi ilman rivinvaihtoja tai ohjausmerkkejä.", + "sheet.personal_rules.invalid_values": "Täydennä säännön arvot: jokaisen on toimittava tässä mallissa, paksuuden on oltava positiivinen ja K-kertoimen välillä 0–1.", + "sheet.personal_rules.missing": "Valittu henkilökohtainen sääntö ei ole enää käytettävissä.", + "sheet.personal_rules.name": "Säännön nimi", + "sheet.personal_rules.name_exists": "Samanniminen henkilökohtainen sääntö on jo olemassa. Käytä eri nimeä.", + "sheet.personal_rules.name_hint": "Yksilöllinen nimi", + "sheet.personal_rules.overwrite": "Tallenna muutokset valittuun henkilökohtaiseen sääntöön", + "sheet.personal_rules.rename": "Nimeä uudelleen", + "sheet.personal_rules.rule": "Henkilökohtainen sääntö", + "sheet.personal_rules.save": "Tallenna uutena", + "sheet.personal_rules.scope": "Tallennettu profiiliisi kaikkia malleja varten.", + "sheet.personal_rules.select": "Valitse sääntö", + "sheet.personal_rules.title": "Henkilökohtaiset säännöt", + "sheet.personal_rules.unsupported": "Säännössä on arvoja, joita tämä versio ei voi käyttää.", + "shortcut.json_error": "parametrien on oltava JSONia: {error}", + "shortcut.no_command": "komentoa `{id}` ei ole; JSONia edeltävän tekstin on oltava komennon tunnus", + "shortcut.pin": "Kiinnitä S-ruudun ylälaitaan", + "shortcut.unpin": "Irrota", + "sketch is missing": "luonnos puuttuu", + "sketch.degrees_of_freedom": "{count} vapausastetta", + "sketch.dimension_added_driven": "Luonnos määrää jo tämän mitan, joten se lisättiin ohjattuna (viite)mittana", + "sketch.dimension_applied": "Mitta lisätty", + "sketch.dimension_applied_defined": "Mitta lisätty; parametri `{name}` määritelty", + "sketch.dimension_applied_renamed": "Mitta lisätty; parametrin nimi `{name}`", + "sketch.dof_left": "{count} vapausastetta jäljellä", + "sketch.dof_left_one": "{count} vapausaste jäljellä", + "sketch.draw_curves": "Piirrä tyypillä {linetype}", + "sketch.make_selection": "Muunna valinta tyypiksi {linetype}", + "sketch.open_ends": "Avoimia {count}", + "sketch.status_conflict": "rajoitteet ovat ristiriidassa", + "sketch.status_fully_constrained": "täysin rajoitettu", + "sketch.untied_ends": "Irrallisia {count}", + "sketch_panel.attached": "Kiinnitetty: {body} / {path}", + "sketch_panel.author": "Laatijan sijoittelu: {path}", + "sketch_panel.dimension_hint": "Anna arvo tai lauseke ja paina OK.", + "sketch_panel.move_hint": "Anna etäisyydet ja kulma ja paina OK.", + "sketch_panel.plane_angle": "{plane}, käännetty kulmaan {angle}", + "sketch_panel.plane_fixed": "Kiinteä taso", + "sketch_panel.plane_flipped": "{plane}, sen toiselta puolelta", + "sketch_panel.plane_offset": "{plane}, siirtymä {distance}", + "sketch_panel.plane_origin": "Taso {plane}", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Kiinnitetty ohutlevykappaleen tasopintaan; seuraa kyseistä paneelia, kun levy muuttuu.", + "sketch_panel.text_hint": "Muokkaa tekstiä ja sen sijoittelua ja paina OK.", + "sketch_panel.use_plane": "Käytä tasoa {plane}", + "sketch_text.align_center": "Keskitetty", + "sketch_text.align_left": "Vasen", + "sketch_text.align_right": "Oikea", + "sketch_text.bold": "Lihavoitu", + "sketch_text.builtin_font": "{family} (sisäinen)", + "sketch_text.italic": "Kursivoitu", + "sketch_text.loading_fonts": "Etsitään asennettuja fontteja…", + "sketch_text.on_path": "Teksti seuraa polkuaan: käyrä sijoittaa sen", + "sketch_text.path_clear": "Irrota teksti käyrältään", + "sketch_text.path_hint": "Luonnoksen käyrä, jota teksti seuraa: napsauta tätä ja sitten käyrää", + "sketch_text.use_font_file": "Käytä fonttitiedostoa {path}", + "solid": "yhtenäinen", + "status.editing": "Muokataan: {name}", + "status.navigation": "Hiiren toiminnot: {mapping}", + "status.navigation_tip": "Miten hiiri liikuttaa näkymää. Valitse toinen määritys napsauttamalla", + "status.under_constrained": "Alimääritelty ({count} vapausastetta)", + "status.under_constrained_one": "Alimääritelty ({count} vapausaste)", + "status.units_tip": "Mallin yksiköt. Muuta niitä napsauttamalla", + "the angle must be a number": "kulman on oltava luku", + "the curves must be in one sketch": "käyrien on oltava samassa luonnoksessa", + "the direction must be an axis, a sketch line or a straight edge": "suunnan on oltava akseli, luonnosviiva tai suora särmä", + "the duration must be a number of seconds, 0.001 or more": "keston on oltava sekunteina annettu luku, vähintään 0.001", + "the line's sketch is gone": "viivan luonnos on poistunut", + "the lines must be in one sketch": "viivojen on oltava samassa luonnoksessa", + "the lines must be in one sketch, not the section's": "viivojen on oltava samassa luonnoksessa, ei leikkauksen luonnoksessa", + "the parts library is empty": "osakirjasto on tyhjä", + "the parts library is not available": "osakirjasto ei ole käytettävissä", + "the path curves must be in one other sketch": "polkukäyrien on oltava yhdessä toisessa luonnoksessa", + "the path's sketch is gone": "polun luonnos on poistunut", + "the position must be a number": "sijainnin on oltava luku", + "the preview failed": "esikatselu epäonnistui", + "the profiles and curves must be in one sketch": "profiilien ja käyrien on oltava samassa luonnoksessa", + "the reference face must be on the edges' body": "viitepinnan on oltava särmien kappaleessa", + "the scale must be above 0": "mittakaavan on oltava suurempi kuin 0", + "the section's profiles must be in one sketch": "poikkileikkauksen profiilien on oltava samassa luonnoksessa", + "the two origins are on the same component": "molemmat origot ovat samassa komponentissa", + "thread.automatic_size": "Automaattinen koko", + "thread.designation": "Merkintä", + "thread.direction": "Kierteen suunta", + "thread.iso_metric": "ISO-metrinen", + "thread.left_hand": "Vasenkätinen", + "thread.right_hand": "Oikeakätinen", + "thread.select_size": "Valitse koko", + "timeline.delete_after_marker_one": "Poista 1 piirre historiamerkin jälkeen", + "timeline.delete_after_marker_other": "Poista {count} piirrettä historiamerkin jälkeen", + "timeline.delete_folders_tip": "Säilytä niiden piirteet ja sisäkkäiset kansiot historiassa", + "timeline.delete_folders_with_features_tip": "Poista kansiot kaikkine niiden piirteineen ja sisäkkäisine kansioineen", + "timeline.delete_many": "Poista {count} valittua piirrettä", + "timeline.edited_rolled_back": "{name} muuttui, mutta se on historiamerkin jälkeen, joten sitä ei rakenneta: siirrä merkki sen ohi nähdäksesi tuloksen", + "timeline.feature_error": "Virhe: {message}", + "timeline.feature_hint": "Kaksoisnapsauta muokataksesi · vedä järjestääksesi · napsauta hiiren kakkospainikkeella lisää", + "timeline.feature_rolled_back": "Palautettu: historiamerkin jälkeen, joten ei rakennettu", + "timeline.feature_warning": "Varoitus: {message}", + "timeline.group": "Uusi piirrekansio", + "timeline.move_many": "Siirrä aikajanan kohteita", + "timeline.move_to_group": "Siirrä piirteet kansioon", + "timeline.rename_group": "Nimeä piirrekansio uudelleen", + "timeline.reorder_many": "Järjestä piirteet uudelleen", + "timeline.suppress_features": "Ohita piirteet", + "timeline.suppress_folders_tip": "Ohita tai palauta käyttöön kaikki kansioiden ja niiden sisäkkäisten kansioiden piirteet", + "timeline.ungroup": "Poista piirrekansiot", + "timeline.unsuppress_features": "Palauta piirteet käyttöön", + "to the face": "pintaan", + "to the next face": "seuraavaan pintaan", + "toolbar.customize_note": "Välilehti {tab}. Rastita komento antaaksesi sille työkalurivin painikkeen. Voit myös vetää paneelin pudotusvalikon rivin työkaluriville, vetää painikkeen pois työkaluriviltä poistaaksesi sen tai käyttää pudotusvalikon rivin kiinnitysnastaa.", + "toolbar.image": "KUVA", + "toolbar.render": "RENDERÖINTI", + "toolbar.render_appearance": "ULKOASU", + "toolbar.render_camera": "KAMERA", + "toolbar.render_environment": "YMPÄRISTÖ", + "toolbar.render_lighting": "VALAISTUS", + "toolbar.render_lights": "VALOT", + "toolbar.render_result": "TULOS", + "toolbar.render_scene": "NÄKYMÄ", + "toolbar.render_stage": "NÄYTTÄMÖ", + "toolbar.render_studio": "STUDIO", + "trace.points": "{count} pistettä polkua pitkin", + "trace.stopped_by_contact": "Päättyy näytteeseen {sample}: {a} kulkisi kohteen {b} läpi", + "trace.stopped_by_loop": "Päättyy näytteeseen {sample}: nivelen {joint} kautta kulkeva silmukka ei voi sulkeutua enempää", + "trim by sketch curves, or by one plane or face": "katkaise luonnoskäyrillä tai yhdellä tasolla tai pinnalla", + "ui.1_degree_of_freedom": "1 vapausaste", + "ui.3d_mouse_axes_reassigned": "Akselit on määritetty uudelleen asetuksissa, ja se pätee edelleen tämän kalibroinnin päälle.", + "ui.3d_mouse_button": "Painike {number}", + "ui.3d_mouse_button_nothing": "Ei mitään", + "ui.3d_mouse_button_settings": "3D-hiiren asetukset", + "ui.3d_mouse_buttons": "Painikkeet", + "ui.3d_mouse_buttons_hint": "Paina 3D-hiiren painiketta löytääksesi sen tästä ja valitse sitten, mitä se tekee.", + "ui.3d_mouse_buttons_no_device": "Liitä 3D-hiiri, jotta voit määrittää sen painikkeiden toiminnot.", + "ui.3d_mouse_buttons_reset": "Palauta oletuspainikkeet", + "ui.3d_mouse_calibrate": "Kalibroi…", + "ui.3d_mouse_calibrate_tip": "Liikuta nuppia ikkunan ohjeiden mukaan, jotta SolidCraft oppii, mihin suuntaan ja kuinka pitkälle tämän 3D-hiiren kukin akseli liikkuu. Tallennetaan tälle 3D-hiirelle.", + "ui.3d_mouse_calibrate_title": "Kalibroi 3D-hiiri", + "ui.3d_mouse_calibrate_wait": "Liikuta 3D-hiirtä kerran, jotta SolidCraft havaitsee sen.", + "ui.3d_mouse_calibrated": "Kalibroitu", + "ui.3d_mouse_calibration": "Kalibrointi", + "ui.3d_mouse_calibration_done": "Valmis. Tallenna se, niin sitä käytetään aina, kun tämä 3D-hiiri on liitettynä.", + "ui.3d_mouse_calibration_no_response": "Yksi akseli liikkui tuskin lainkaan, joten sitä ei voi kalibroida. Aloita alusta ja liikuta nuppia joka kerta pidemmälle.", + "ui.3d_mouse_circles_help": "Ympyrät näyttävät, mitä laite lähettää kalibroinnin jälkeen. Testimalli näyttää, mitä näkymä tekee sillä käännön, nopeuden ja akselimäärityksen jälkeen.", + "ui.3d_mouse_dead_zone": "Kuollut alue", + "ui.3d_mouse_dead_zone_tip": "Kuinka paljon nupin on liikuttava ennen kuin näkymä liikkuu, osuutena sen koko liikealueesta. Suurenna arvoa, jos näkymä ajelehtii nupin ollessa levossa.", + "ui.3d_mouse_denied": "SolidCraft havaitsee 3D-hiiren, mutta sillä ei ole lupaa lukea sitä. Katso udev-sääntö 3D-hiiren asennusohjeista.", + "ui.3d_mouse_denied_short": "Löytyi, mutta ei luettavissa", + "ui.3d_mouse_driven_by": "Mikä laitteen kalibroitu liike ohjaa tätä akselia. Jos valitset liikkeen, jota toinen akseli jo käyttää, ne vaihtavat paikkaa.", + "ui.3d_mouse_driven_by_label": "ohjaa", + "ui.3d_mouse_driver": "Ajuri", + "ui.3d_mouse_driver_auto": "Automaattinen", + "ui.3d_mouse_driver_direct": "Suora (hidraw)", + "ui.3d_mouse_driver_fallback": "Luetaan toistaiseksi spacenavd:n kautta: itse laitetta ei voitu avata.", + "ui.3d_mouse_driver_only_direct": "Laite luetaan suoraan; spacenavd ei ole käynnissä", + "ui.3d_mouse_driver_only_spacenavd": "Luetaan spacenavd:n kautta, ainoa käytettävissä oleva tapa", + "ui.3d_mouse_forget": "Unohda kalibrointi", + "ui.3d_mouse_ghost_left_viewport": "Vasen näkymäikkuna", + "ui.3d_mouse_ghost_left_window": "Vasen ikkuna", + "ui.3d_mouse_ghost_position": "Sijainti", + "ui.3d_mouse_ghost_right_viewport": "Oikea näkymäikkuna", + "ui.3d_mouse_hint": "Siirrä ja kierrä näkymää samanaikaisesti; luonnoksissa vain panorointi ja zoomaus.", + "ui.3d_mouse_kind_cap": "Palautuu keskelle", + "ui.3d_mouse_kind_question": "Millainen 3D-hiiri on kyseessä?", + "ui.3d_mouse_kind_trackball": "Ohjauspallo", + "ui.3d_mouse_looks_like_cap": "Lukema pysyy paikallaan, kun pidät otetta, kuten keskelle palautuvassa laitteessa.", + "ui.3d_mouse_looks_like_trackball": "Lukema palautuu heti, kun lopetat liikuttamisen, kuten ohjauspallossa.", + "ui.3d_mouse_motion_rotation_x": "Kierto X", + "ui.3d_mouse_motion_rotation_y": "Kierto Y", + "ui.3d_mouse_motion_rotation_z": "Kierto Z", + "ui.3d_mouse_motion_translation_x": "Siirto X", + "ui.3d_mouse_motion_translation_y": "Siirto Y", + "ui.3d_mouse_motion_translation_z": "Siirto Z", + "ui.3d_mouse_navigate": "Liiku 3D-hiirellä", + "ui.3d_mouse_none": "3D-hiirtä ei löytynyt", + "ui.3d_mouse_not_calibrated": "Ei kalibroitu", + "ui.3d_mouse_offer": "3D-hiiren kalibrointi korjaa vaihtuneet tai käänteiset akselit ja asettaa sen nopeuden ja tuntuman.", + "ui.3d_mouse_offer_never": "Älä näytä enää", + "ui.3d_mouse_offer_swap": "Vaihda hidraw-ajuriin (suora ajuri)", + "ui.3d_mouse_orbit": "Kiertoliike", + "ui.3d_mouse_orbit_classic": "Perinteinen", + "ui.3d_mouse_orbit_tip": "Jäljitetty: 3D-hiiri kiertää näkymää sen mallin kohdan ympäri, joka on näkymän keskellä kierron alkaessa, merkittynä himmeällä pallolla. Perinteinen: näkymän keskipisteen ympäri", + "ui.3d_mouse_orbit_traced": "Jäljitetty", + "ui.3d_mouse_reading": "Lukema", + "ui.3d_mouse_reset_axes": "Nollaa akselimääritys", + "ui.3d_mouse_reset_view": "Nollaa näkymä", + "ui.3d_mouse_restore": "Palauta 3D-hiiren oletukset", + "ui.3d_mouse_restore_ask": "Palautetaanko 3D-hiiren asetukset oletuksiin? Kalibrointi ja painikemääritykset säilyvät.", + "ui.3d_mouse_section": "3D-hiiri", + "ui.3d_mouse_show_in_view": "Näytä 3D-hiiri 3D-näkymässä", + "ui.3d_mouse_show_in_view_tip": "Kuva 3D-hiirestäsi vasemmassa alakulmassa, liikkuu kun liikutat hiirtä.", + "ui.3d_mouse_skip": "Laitteeni ei pysty tähän", + "ui.3d_mouse_skip_tip": "Ohittaa tämän liikkeen molempiin suuntiin, ja kalibrointi jättää sen pois.", + "ui.3d_mouse_speed": "3D-hiiren nopeus", + "ui.3d_mouse_step_lift": "Nosta nuppia ylös, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_press": "Paina nuppia alas, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_pull": "Vedä nuppia itseäsi kohti, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_push": "Työnnä nuppia itsestäsi poispäin, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_rest": "Ota käsi pois nupilta ja anna sen olla paikallaan.", + "ui.3d_mouse_step_rest_trackball": "Ota käsi pois pallolta ja anna sen olla paikallaan.", + "ui.3d_mouse_step_ring_clockwise": "Käännä vierityskehää myötäpäivään muutama napsaus.", + "ui.3d_mouse_step_ring_counterclockwise": "Käännä vierityskehää vastapäivään muutama napsaus.", + "ui.3d_mouse_step_roll_away": "Pyöritä palloa itsestäsi poispäin muutaman kerran ja anna sen sitten pysähtyä.", + "ui.3d_mouse_step_roll_left": "Pyöritä palloa vasemmalle muutaman kerran ja anna sen sitten pysähtyä.", + "ui.3d_mouse_step_roll_right": "Pyöritä palloa oikealle muutaman kerran ja anna sen sitten pysähtyä.", + "ui.3d_mouse_step_roll_toward": "Pyöritä palloa itseäsi kohti muutaman kerran ja anna sen sitten pysähtyä.", + "ui.3d_mouse_step_slide_left": "Liu'uta nuppia vasemmalle, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_slide_right": "Liu'uta nuppia oikealle, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_spin_clockwise": "Kierrä palloa myötäpäivään muutaman kerran ja anna sen sitten pysähtyä.", + "ui.3d_mouse_step_spin_counterclockwise": "Kierrä palloa vastapäivään muutaman kerran ja anna sen sitten pysähtyä.", + "ui.3d_mouse_step_tilt_away": "Kallista nupin yläosaa itsestäsi poispäin (lähin reuna nousee), pidä ja päästä sitten irti.", + "ui.3d_mouse_step_tilt_left": "Kallista nuppia vasemmalle (vasen reuna alas), pidä ja päästä sitten irti.", + "ui.3d_mouse_step_tilt_right": "Kallista nuppia oikealle (oikea reuna alas), pidä ja päästä sitten irti.", + "ui.3d_mouse_step_tilt_toward": "Kallista nupin yläosaa itseäsi kohti (lähin reuna laskee), pidä ja päästä sitten irti.", + "ui.3d_mouse_step_touch": "Lepuuta kättäsi nupilla painamatta sitä.", + "ui.3d_mouse_step_twist_clockwise": "Kierrä nuppia myötäpäivään, pidä ja päästä sitten irti.", + "ui.3d_mouse_step_twist_counterclockwise": "Kierrä nuppia vastapäivään, pidä ja päästä sitten irti.", + "ui.3d_mouse_switch_cap": "Kalibroi keskelle palautuvana", + "ui.3d_mouse_switch_trackball": "Kalibroi ohjauspallona", + "ui.3d_mouse_use_direct": "Käytä suoraa ajuria", + "ui.3d_mouse_use_direct_tip": "Lue 3D-hiirtä suoraan (hidraw) spacenavd:n sijaan.", + "ui.3d_mouse_view_axis": "Näkymän akseli", + "ui.3d_mouse_view_off": "Pois", + "ui.3d_mouse_view_off_tip": "3D-hiiren ajurin profiili poistaa tämän akselin käytöstä, joten sen liike ei liikuta näkymää. Jos valitset tähän toisen akselin liikkeen, ne vaihtavat paikkaa, ja se liike lakkaa liikuttamasta näkymää.", + "ui.3d_mouse_view_orbit_sideways": "Kierto Y", + "ui.3d_mouse_view_orbit_up_down": "Kierto X", + "ui.3d_mouse_view_pan_sideways": "Panorointi X", + "ui.3d_mouse_view_pan_up_down": "Panorointi Y", + "ui.3d_mouse_view_roll": "Kallistus", + "ui.3d_mouse_view_unmapped": "Akseli {n}", + "ui.3d_mouse_view_zoom": "Zoomaus", + "ui.3d_mouse_waiting": "Odotetaan liikettä", + "ui.3d_mouse_wiggle": "Jos sellainen on liitettynä, heiluttele sitä.", + "ui.3d_sketch": "3D-luonnos", + "ui.activate": "Aktivoi", + "ui.active_component": "Aktiivinen komponentti", + "ui.add_configuration": "Lisää konfiguraatio", + "ui.add_parameter": "Lisää parametri", + "ui.agent_chat": "Agenttikeskustelu", + "ui.agent_chat_add": "Liitä tiedostoja", + "ui.agent_chat_add_file": "Valitse tiedosto…", + "ui.agent_chat_allow": "Salli", + "ui.agent_chat_allow_once": "Salli kerran", + "ui.agent_chat_allowed": "Sallittu", + "ui.agent_chat_answered": "Vastaus: {option}", + "ui.agent_chat_approval": "Sallitaanko agentin tehdä tämä?", + "ui.agent_chat_attach_selection": "Liitä valinta", + "ui.agent_chat_attach_selection_tip": "Lähetä mallista valitut kohteet (pinnat, särmät, piirteet, kappaleet) viestin mukana, jotta agentti tietää, mitä tarkoitat", + "ui.agent_chat_attach_view": "Liitä näkymä", + "ui.agent_chat_attach_view_tip": "Lähetä kuva nykyisestä näkymästä viestin mukana", + "ui.agent_chat_attachable": "Kuvat, PDF:t, teksti- ja CAD-tiedostot", + "ui.agent_chat_auth_agent": "Kirjaudu palvelulla {name}", + "ui.agent_chat_auth_env": "Aseta {vars} ympäristöösi ja käynnistä SolidCraft uudelleen.", + "ui.agent_chat_auth_needed": "Agentti pyytää sinua kirjautumaan. Kirjaudut agentin itsensä kautta; SolidCraft ei koskaan näe salasanaasi tai avaimiasi.", + "ui.agent_chat_auth_none": "Agentti ei tarjoa tässä kirjautumistapaa: kirjaudu sen omien ohjeiden mukaisesti.", + "ui.agent_chat_auth_retry": "Olen kirjautunut: yhdistä uudelleen", + "ui.agent_chat_auth_terminal": "Suorita tämä päätteessä ja paina sitten Yhdistä uudelleen:", + "ui.agent_chat_auth_title": "Kirjaudu agenttiin", + "ui.agent_chat_cancel": "Peruuta", + "ui.agent_chat_changes": "Muutoksia: {n}, yksi kumoamisvaihe", + "ui.agent_chat_changes_n": "{n} muutosta", + "ui.agent_chat_changes_of": "{n}/{limit} muutosta", + "ui.agent_chat_choose_model": "Valitse tekoälymalli", + "ui.agent_chat_close": "Sulje", + "ui.agent_chat_command": "Agentin komento", + "ui.agent_chat_connect": "Yhdistä", + "ui.agent_chat_copy": "Kopioi", + "ui.agent_chat_ctx_bodies": "Kappaleet", + "ui.agent_chat_ctx_elements": "Elementit", + "ui.agent_chat_ctx_features": "Piirteet", + "ui.agent_chat_ctx_more": "+{n} lisää", + "ui.agent_chat_ctx_other": "Muut", + "ui.agent_chat_custom_hint": "Mikä tahansa ohjelma, joka puhuu Agent Client Protocolia stdinissä ja stdoutissa. Sitä ei ajeta komentotulkin kautta.", + "ui.agent_chat_denied": "Estetty", + "ui.agent_chat_deny": "Estä", + "ui.agent_chat_desktop_only": "Agenttikeskustelu vaatii työpöytäsovelluksen.", + "ui.agent_chat_detecting": "Etsitään agentteja…", + "ui.agent_chat_dock": "Telakoi sivupaneeliin", + "ui.agent_chat_dock_left": "Telakoi vasemmalle", + "ui.agent_chat_dock_right": "Telakoi oikealle", + "ui.agent_chat_drop_hint": "Pudota liittääksesi seuraavaan viestiin", + "ui.agent_chat_edit": "Muokkaa", + "ui.agent_chat_exited": "Agentti pysähtyi.", + "ui.agent_chat_failed_start": "Agenttia ei voitu käynnistää: {error}", + "ui.agent_chat_file_cad_not_read": "{name}: CAD-tiedosto tuodaan malliin, sitä ei lueta", + "ui.agent_chat_file_empty_picture": "{name}: tyhjä kuva", + "ui.agent_chat_file_error": "{name}: {error}", + "ui.agent_chat_file_heic": "{name}: HEIC-kuvia ei voi lukea tässä; vie se ensin JPEG- tai PNG-muotoon", + "ui.agent_chat_file_importing": "Tuodaan malliin…", + "ui.agent_chat_file_kinds": "{name}: liitettävissä ovat kuvat (PNG, JPEG, GIF, WebP, BMP, TIFF), tekstitiedostot (myös SVG) ja CAD-tiedostot (STEP, IGES, FreeCAD, Rhino, STL, 3MF, OBJ, glTF)", + "ui.agent_chat_file_loading": "Luetaan tiedostoa…", + "ui.agent_chat_file_not_a_file": "{name}: ei ole tiedosto", + "ui.agent_chat_file_not_a_picture": "{name}: ei ole kuva", + "ui.agent_chat_file_not_utf8": "{name}: ei ole UTF-8-tekstiä", + "ui.agent_chat_file_picture_too_big": "{name}: kuva voi olla enintään {max} Mt", + "ui.agent_chat_file_text_too_big": "{name}: tekstitiedosto voi olla enintään {max} kt", + "ui.agent_chat_file_too_large": "{name}: liian suuri", + "ui.agent_chat_file_too_large_to_send": "{name}: liian suuri lähetettäväksi, pienennettynäkin", + "ui.agent_chat_file_unreadable_picture": "{name}: kuva, jota ei voi lukea", + "ui.agent_chat_files": "Tiedostot: {files}", + "ui.agent_chat_files_left_out": "Yhden viestin mukana voi olla enintään {max} tiedostoa; pois jätetyt: {files}", + "ui.agent_chat_files_loading": "Odotetaan tiedostojen lukemista", + "ui.agent_chat_files_max": "Enintään {max} tiedostoa yhdessä viestissä", + "ui.agent_chat_flatpak_none_found": "Koodausagenttia ei löytynyt. Flatpak-hiekkalaatikko ei näe kotikansioosi asennettuja agentteja, eikä sillä oletuksena ole verkkoyhteyttä. Jos haluat sallia nvm:n tai ~/.local/bin:n kautta asennetun Claude Coden ja sen ACP-sovittimen, suorita tämä komento päätteessä ja käynnistä sitten SolidCraft uudelleen. Se antaa pääsyn luetteloituun ohjelmaan ja kirjautumistiedostoihin sekä verkkoon:", + "ui.agent_chat_import_failed": "{name}: tuonti epäonnistui: {error}", + "ui.agent_chat_intro": "Keskustele jo käyttämäsi koodausagentin tai valitsemasi mallipalvelun kanssa. Se työskentelee avoimen mallin parissa SolidCraftin työkaluilla: sen muutokset näkyvät sitä mukaa kuin se tekee niitä, ja ne kumotaan yhtenä vaiheena viestiä kohden. Mitä se saa tehdä kysymättä, määritetään kohdassa Asetukset > AI.", + "ui.agent_chat_key_in_env": "avain asetettu", + "ui.agent_chat_last_missing": "Viimeksi käytetty agentti ({agent}) ei ole enää käytettävissä. Valitse agentti ja paina sitten Yritä uudelleen.", + "ui.agent_chat_left": "Vasen", + "ui.agent_chat_limit": "Agentti saavutti yhden viestin {limit} muutoksen rajan. Lähetä uusi viesti, niin se voi jatkaa.", + "ui.agent_chat_login_found": "kirjautuminen löytyi", + "ui.agent_chat_minimize": "Pienennä palkiksi", + "ui.agent_chat_mode_all": "Kaikki", + "ui.agent_chat_mode_ask": "Kysy", + "ui.agent_chat_mode_edits": "Muokkaukset", + "ui.agent_chat_model_no_tools": "{model} ei osaa käyttää SolidCraftin työkaluja, joten se voi vain keskustella. Valitse tekoälymalli, joka tukee työkaluja.", + "ui.agent_chat_model_tip": "Agentin käyttämä tekoälymalli. Valintasi muistetaan tälle agentille.", + "ui.agent_chat_new": "Uusi keskustelu", + "ui.agent_chat_new_confirm": "Aloitetaanko uusi keskustelu? Nykyinen tyhjennetään.", + "ui.agent_chat_next_picture": "Seuraava kuva", + "ui.agent_chat_no_cli": "solidcraft-cli:tä ei löytynyt sovelluksen vierestä, joten agentti ei pääse malliin.", + "ui.agent_chat_no_control": "Agenttikeskustelun päätepiste ei käynnistynyt, joten agentti ei pääse malliin.", + "ui.agent_chat_no_images": "Tämä agentti ei vastaanota kuvia", + "ui.agent_chat_no_picture": "Leikepöydällä ei ole kuvaa", + "ui.agent_chat_no_tool_call": "Tekoälymalli ei käyttänyt SolidCraftin työkaluja, joten mallia ei muutettu. Valitse tekoälymalli, joka tukee työkalukutsuja; katso agenttikeskustelun oppaasta kohta Paikalliset mallit.", + "ui.agent_chat_none_found": "Koodausagenttia ei löytynyt. Asenna jokin näistä ja paina sitten Päivitä:", + "ui.agent_chat_none_found_short": "Agenttia ei löytynyt", + "ui.agent_chat_not_installed": "Ei asennettu", + "ui.agent_chat_not_signed_in": "ei kirjautunut", + "ui.agent_chat_open_settings": "Avaa asetukset", + "ui.agent_chat_own_window": "Oma ikkuna", + "ui.agent_chat_paste_picture": "Liitä kuva", + "ui.agent_chat_permission": "Agentti pyytää lupaa suorittaa: {title}", + "ui.agent_chat_pick": "Agentti", + "ui.agent_chat_pick_context": "Valitse konteksti", + "ui.agent_chat_pick_context_tip": "Valitse mallista pinnat, särmät, piirteet ja kappaleet agentin tarkasteltaviksi ja paina sitten OK", + "ui.agent_chat_placeholder": "Pyydä agenttia muuttamaan mallia…", + "ui.agent_chat_placeholder_busy": "Lisää viesti jonoon odottamaan agentin valmistumista (Ctrl+Enter: lähetä heti)", + "ui.agent_chat_pop_out": "Avaa omaan ikkunaan, jonka voit siirtää toiselle näytölle", + "ui.agent_chat_previous_picture": "Edellinen kuva", + "ui.agent_chat_privacy": "Mitään ei lähetetä minnekään muualle kuin käyttämällesi agentille omalla tililläsi.", + "ui.agent_chat_privacy_note": "Keskustelu itse ei lähetä mitään minnekään: käyttämäsi agentti kommunikoi oman palvelunsa kanssa omalla tililläsi. SolidCraft ei koskaan näe eikä tallenna avainta tai salasanaa.", + "ui.agent_chat_provider_models": "{count} mallia", + "ui.agent_chat_provider_other": "Muu", + "ui.agent_chat_provider_tip": "Palveluntarjoaja, jonka mallit seuraava luettelo näyttää.", + "ui.agent_chat_queue": "Jono", + "ui.agent_chat_queue_full": "Liian monta viestiä odottaa: poista yksi tai odota agenttia.", + "ui.agent_chat_queued": "Jonossa", + "ui.agent_chat_queued_stopped": "Jonossa (pysäytetty, ei lähetetty)", + "ui.agent_chat_refresh": "Päivitä", + "ui.agent_chat_refused": "Ei sallittu agentille: {reason}", + "ui.agent_chat_remove": "Poista jonosta", + "ui.agent_chat_remove_file": "Poista tästä viestistä", + "ui.agent_chat_remove_imported": "Poista tästä viestistä; osa jää malliin (Kumoa poistaa sen)", + "ui.agent_chat_retry": "Yritä uudelleen", + "ui.agent_chat_right": "Oikea", + "ui.agent_chat_save": "Tallenna", + "ui.agent_chat_send": "Lähetä", + "ui.agent_chat_send_now": "Lähetä heti", + "ui.agent_chat_send_now_tip": "Keskeytä agentin nykyinen vaihe ja lähetä tämä seuraavaksi (Ctrl+Enter)", + "ui.agent_chat_setup_body": "SolidCraft keskustelee tähän tietokoneeseen asennetun koodausagentin kanssa. Valitse se kohdassa Asetukset › AI.", + "ui.agent_chat_setup_title": "Ota avustaja käyttöön", + "ui.agent_chat_side": "Agenttikeskustelun puoli", + "ui.agent_chat_signed_in": "kirjautunut", + "ui.agent_chat_state_auth": "Kirjautuminen tarvitaan", + "ui.agent_chat_state_busy": "Työskennellään…", + "ui.agent_chat_state_exited": "Agentti pysähtyi", + "ui.agent_chat_state_failed": "Yhteys epäonnistui", + "ui.agent_chat_state_off": "Ei yhdistetty", + "ui.agent_chat_state_ready": "Valmis", + "ui.agent_chat_state_starting": "Käynnistetään…", + "ui.agent_chat_stop": "Pysäytä", + "ui.agent_chat_stop_tip": "Pysäytä agentti ja kaikki, mitä se käynnisti", + "ui.agent_chat_stop_turn_tip": "Pysäytä agentti (Esc). Jonossa olevat viestit pysyvät jonossa.", + "ui.agent_chat_turn_limit": "Muutoksia viestiä kohden", + "ui.agent_chat_turn_unlimited": "Rajoittamaton", + "ui.agent_chat_undo_changes": "Kumoa nämä muutokset", + "ui.agent_chat_undo_label": "Agenttikeskustelu: {message}", + "ui.agent_chat_undone": "(kumottu)", + "ui.agent_chat_view_attached": "Näkymä liitetty", + "ui.agent_chat_waiting": "Odottaa sinua", + "ui.agent_cursor": "Agentin kohdistin", + "ui.agent_cursor_speed": "Agentin kohdistimen nopeus", + "ui.agent_off_thread": "Pidä ikkuna reagoivana agenttien suorittaessa komentoja", + "ui.agent_off_thread_note": "Agentin hitaat komennot (AI-paneelista tai MCP-asiakkaasta) lasketaan ikkunan rinnalla: voit edelleen kääntää ja tarkastella näkymää ja pysäyttää komennon näkymän merkistä. Pois: kukin suoritetaan ikkunan säikeessä, ja ikkuna odottaa sitä.", + "ui.ai_add": "Lisää", + "ui.ai_add_agent": "Lisää agentti…", + "ui.ai_add_agent_title": "Lisää agentti", + "ui.ai_add_anthropic": "Anthropic-yhteensopiva API", + "ui.ai_add_api": "OpenAI-yhteensopiva API", + "ui.ai_add_custom": "Mukautettu komento", + "ui.ai_add_endpoint": "API-päätepiste", + "ui.ai_add_installed": "Asennettu agentti", + "ui.ai_add_installed_note": "Oma merkintä asennetulle agentille, esimerkiksi muilla argumenteilla. Asennetut agentit luetellaan automaattisesti.", + "ui.ai_added": "lisätty", + "ui.ai_agent_mode_tip": "Agentin oma tila: sen omat työkalut (komentotulkki, tiedostomuokkaukset) noudattavat sitä. SolidCraftin työkalut noudattavat käyttöoikeuksia kohdassa Asetukset > AI.", + "ui.ai_agents": "Agentit", + "ui.ai_always_allow": "Salli aina", + "ui.ai_always_allow_tip": "Salli {tool} tälle agentille tästä lähtien kysymättä", + "ui.ai_always_allowed": "Aina sallitut", + "ui.ai_always_allowed_none": "Mitään ei ole vielä sallittu aina. Keskustelussa antamasi \"Salli aina\" -vastaukset luetellaan tässä.", + "ui.ai_auto_allowed": "Sallittu automaattisesti: {title} ({why})", + "ui.ai_auto_mode": "käyttöoikeusasetuksesi", + "ui.ai_auto_ours": "SolidCraft tarkistaa omat työkalunsa itse", + "ui.ai_auto_remembered": "valitsit Salli aina", + "ui.ai_bad_url": "Anna URL, joka alkaa http:// tai https://", + "ui.ai_base_url": "Perus-URL", + "ui.ai_button": "AI", + "ui.ai_command_hint": "my-agent --acp", + "ui.ai_copy_suffix": "kopio", + "ui.ai_custom_url": "Mukautettu URL", + "ui.ai_default": "Oletus", + "ui.ai_http_busy": "päätepiste on varattu tai ylittänyt rajansa; yritä myöhemmin uudelleen", + "ui.ai_http_key": "päätepiste hylkäsi avaimen: tarkista se kohdassa Asetukset > AI", + "ui.ai_http_not_found": "päätepistettä tai tekoälymallia ei ole: tarkista perus-URL ja tekoälymallin nimi", + "ui.ai_http_too_large": "keskustelu on liian suuri tälle päätepisteelle; aloita uusi keskustelu", + "ui.ai_key": "API-avain", + "ui.ai_key_hint": "valinnainen; säilytetään järjestelmän avainnipussa", + "ui.ai_key_needs_https": "Avain lähetetään vain HTTPS:n kautta tai tähän tietokoneeseen.", + "ui.ai_key_note": "Tallennetaan järjestelmän avainnippuun, ei koskaan SolidCraftin asetuksiin. Jätä tyhjäksi paikalliselle palvelimelle.", + "ui.ai_keychain_error": "Järjestelmän avainnippu kieltäytyi: {error}", + "ui.ai_kind": "Tyyppi", + "ui.ai_mode_all": "Salli kaikki", + "ui.ai_mode_all_note": "Mitään ei kysytä etukäteen. Kaatumisraportit pysyvät kiellettyinä, ja kansiorajoitukset ovat edelleen voimassa.", + "ui.ai_mode_all_warning": "Agentti voi tallentaa, korvata, viedä ja vaihtaa mallisi kysymättä. Käytä tätä vain luotettavan agentin kanssa ja tallennetuissa malleissa.", + "ui.ai_mode_ask": "Kysy joka kerta", + "ui.ai_mode_ask_note": "Jokainen mallin muutos ja jokainen tiedostotoiminto odottaa Salli-hyväksyntääsi.", + "ui.ai_mode_edits": "Salli mallimuokkaukset automaattisesti", + "ui.ai_mode_edits_note": "Mallinnuskomennot suoritetaan kysymättä; tallennus, avaaminen, vienti ja muut tiedosto- tai tuhoavat toiminnot kysytään edelleen.", + "ui.ai_mode_global": "Kuten yllä asetettu", + "ui.ai_model": "Malli", + "ui.ai_model_hint": "qwen3:14b", + "ui.ai_modes_scope": "Nämä asetukset koskevat SolidCraftin työkaluja. Agentin omat työkalut (sen komentotulkki, sen tiedostomuokkaukset) noudattavat agentin omaa tilaa, jonka valitset keskustelupaneelin otsikosta, kun agentilla on tiloja.", + "ui.ai_name": "Nimi", + "ui.ai_name_hint": "Oma agenttini", + "ui.ai_need_command": "Anna komento, joka käynnistää agentin", + "ui.ai_need_model": "Anna tekoälymallin nimi", + "ui.ai_no_answer": "Agentti ei vastannut ajoissa", + "ui.ai_no_http": "Tämä versio ei voi muodostaa yhteyttä API-päätepisteisiin", + "ui.ai_no_keychain": "Järjestelmän avainnippu ei ole käytettävissä, joten avainta ei voi tallentaa. Paikallinen palvelin ilman avainta toimii silti.", + "ui.ai_panel": "Paneeli", + "ui.ai_permissions": "Käyttöoikeudet", + "ui.ai_preset": "Esiasetus", + "ui.ai_protocol": "Protokolla", + "ui.ai_remove": "Poista", + "ui.ai_request_timed_out": "Tekoälymalli käytti yli {seconds} s. Voit pidentää tämän päätepisteen pyynnön aikakatkaisua kohdassa Asetukset › Avustaja.", + "ui.ai_request_timeout": "Pyynnön aikakatkaisu", + "ui.ai_request_timeout_hint": "Kuinka kauan yksi mallipyyntö tähän päätepisteeseen saa kestää, 30 s:sta tuntiin. Pidennä hitaalle paikalliselle tekoälymallille.", + "ui.ai_reset": "Nollaa", + "ui.ai_settings": "AI-asetukset…", + "ui.ai_show_features": "Näytä AI-toiminnot", + "ui.ai_show_features_note": "Agenttikeskustelu: paneeli, jossa jo käyttämäsi koodausagentti tai valitsemasi mallipalvelu työskentelee avoimen mallin parissa. Pois: ei AI-painiketta, pikanäppäintä, valikkokohtaa tai paneelia, eikä mitään etsitä tai käynnistetä.", + "ui.ai_switch_tip": "Vaihda tämän keskustelun agentti", + "ui.ai_test": "Testaa", + "ui.ai_test_acp_ok": "Vastaa: {agent}", + "ui.ai_test_acp_tip": "Käynnistää agentin hetkeksi, pyytää siltä istunnon ja lopettaa sen taas", + "ui.ai_test_answered": "Yhteys saatu: {model} vastasi", + "ui.ai_test_anthropic_tip": "Lähettää lyhyen viestin päätepisteeseen", + "ui.ai_test_api_tip": "Pyytää päätepisteeltä sen mallit", + "ui.ai_test_no_model": "Yhteys saatu: {count} tekoälymallia, mutta ei {model}", + "ui.ai_test_ok": "Yhteys saatu: {count} mallia", + "ui.ai_testing": "Testataan…", + "ui.ai_tool_arguments": "Argumentit", + "ui.ai_tool_details": "Tiedot", + "ui.ai_tool_done": "valmis", + "ui.ai_tool_failed": "epäonnistui", + "ui.ai_tool_result": "Tulos", + "ui.ai_tool_running": "käynnissä", + "ui.ai_tool_sum_bodies_one": "{count} kappale", + "ui.ai_tool_sum_bodies_other": "{count} kappaletta", + "ui.ai_tool_sum_by": "{value} verran", + "ui.ai_tool_sum_edges_one": "{count} särmä", + "ui.ai_tool_sum_edges_other": "{count} särmää", + "ui.ai_tool_sum_faces_one": "{count} pinta", + "ui.ai_tool_sum_faces_other": "{count} pintaa", + "ui.ai_tool_sum_features_one": "{count} piirre", + "ui.ai_tool_sum_features_other": "{count} piirrettä", + "ui.ai_tool_sum_measured": "Mitattu {what}", + "ui.ai_tool_sum_more": "{steps} ja {count} muuta", + "ui.ai_tool_sum_radius": "r {value}", + "ui.ai_tool_sum_saved": "Tallennettu {file}", + "ui.ai_tool_sum_screenshot": "Kuvakaappaus", + "ui.ai_tool_sum_then": "{first}, sitten {next}", + "ui.ai_tool_unfold": "Napsauta nähdäksesi, mitä se palautti", + "ui.ai_tool_waiting": "odottaa", + "ui.ai_toolbar_button": "Näytä Avustaja-painike sovelluspalkissa", + "ui.ai_toolbar_button_note": "Avustaja-painike sovelluspalkissa. Ctrl+Shift+A ja Näytä-valikko avaavat paneelin edelleen.", + "ui.ai_undo_tip": "Kumoa kaikki, mitä agentti muutti tämän viestin osalta, yhtenä vaiheena", + "ui.ai_url_hint": "https://example.com/v1", + "ui.all": "Kaikki", + "ui.ambient_occlusion": "Ympäristön peittovarjostus", + "ui.ambient_occlusion_help": "Pehmeä varjostus kohdissa, joissa läpinäkymättömät pinnat kohtaavat. Vaatii GPU-näkymäikkunan.", + "ui.analysis": "Analyysi", + "ui.angle": "Kulma", + "ui.angle_decimals": "Kulman desimaalit", + "ui.animateJoint": "Animoi nivel", + "ui.animate_view_changes": "Animoi näkymän muutokset", + "ui.animation": "Animaatio", + "ui.animation_speed": "Animaation nopeus", + "ui.anti_aliasing": "Reunanpehmennys", + "ui.apply": "Käytä", + "ui.arc": "Kaari", + "ui.assistant": "Avustaja", + "ui.assistant_waiting": "{count} hyväksyntää odottaa", + "ui.autosave_every": "Automaattitallennus joka", + "ui.axes": "Akselit", + "ui.axis": "Akseli", + "ui.back": "Takaa", + "ui.base_point": "Peruspiste", + "ui.blocked": "Estetty", + "ui.bodies": "Kappaleet", + "ui.bodies_all_if_none": "Kappaleet (kaikki, jos ei valittu)", + "ui.body_kind_mesh": "Verkkokappale", + "ui.body_kind_solid": "Solidikappale", + "ui.body_kind_surface": "Pintakappale", + "ui.bottom": "Alta", + "ui.box": "Laatikko", + "ui.break": "Katko", + "ui.browser_bodies_one": "{count} kappale", + "ui.browser_bodies_other": "{count} kappaletta", + "ui.browser_collapse": "Kutista selain", + "ui.browser_expand": "Laajenna selain", + "ui.browser_title": "Selain", + "ui.calibrate": "Kalibroi", + "ui.cancel": "Peruuta", + "ui.canvases": "Kankaat", + "ui.center": "Keskipiste", + "ui.center_arc_follows_cursor": "Keskipistekaari seuraa kohdistimen suuntaa", + "ui.center_arc_follows_cursor_tip": "Keskipistekaari kulkee myötä- tai vastapäivään sen mukaan, kumpaan suuntaan kohdistin kiertää keskipisteen. Pois: aina vastapäivään alusta lähtien.", + "ui.centerline": "Keskiviiva", + "ui.chain": "Ketju", + "ui.chamfer": "Viiste", + "ui.chamfer_type": "Viisteen tyyppi", + "ui.change_workspace": "Vaihda työtilaa", + "ui.circle": "Ympyrä", + "ui.circular_pattern": "Ympyräkuvio", + "ui.clear": "Tyhjennä", + "ui.clear_print_face": "Tyhjennä tulostuspinta", + "ui.clear_recent": "Tyhjennä viimeisimmät", + "ui.clear_the_selection": "Tyhjennä valinta", + "ui.click_selects_whole_bodies": "Napsautus valitsee kokonaiset kappaleet", + "ui.click_then_press_key": "Napsauta ja paina sitten uutta näppäintä", + "ui.close": "Sulje", + "ui.coil": "Kierukka", + "ui.coincident": "Yhtenevä", + "ui.collapse": "Kutista", + "ui.collinear": "Kollineaarinen", + "ui.colour_vision": "Värinäkö", + "ui.colour_vision_deutan": "Deutan", + "ui.colour_vision_deutan_tip": "Heikolle tai puuttuvalle vihreän aistimiselle", + "ui.colour_vision_failed": "Epäonnistunut", + "ui.colour_vision_fixed": "Kiinnitetty", + "ui.colour_vision_mode": "Tila", + "ui.colour_vision_note": "Muuttaa merkitystä kantavia värejä: luonnosten tilat, akselit, taivutukset, analyysit ja kuvakkeet. Jos käytät Windowsin värisuodattimia, pidä tämä Pois-tilassa.", + "ui.colour_vision_off": "Pois", + "ui.colour_vision_off_tip": "Teeman omat värit", + "ui.colour_vision_projected": "Projisoitu", + "ui.colour_vision_protan": "Protan", + "ui.colour_vision_protan_tip": "Heikolle tai puuttuvalle punaisen aistimiselle", + "ui.colour_vision_sample": "Näyte", + "ui.colour_vision_tritan": "Tritan", + "ui.colour_vision_tritan_tip": "Heikolle tai puuttuvalle sinisen aistimiselle", + "ui.combine": "Yhdistäminen", + "ui.coming_later": "tulossa myöhemmin", + "ui.command_done": "{command} valmis", + "ui.command_palette_hint": "Kirjoita komento, esim. {example} — tai paina S hakeaksesi", + "ui.concentric": "Samankeskinen", + "ui.cone": "Kartio", + "ui.conic_curve": "Kartioleikkauskäyrä", + "ui.constant": "Vakio", + "ui.constraints_conflict": "Rajoitteet ovat ristiriidassa", + "ui.construction": "Apugeometria", + "ui.construction.angle_hint": "Apuviiva: napsauta pistettä ja kirjoita sitten kulma tai valitse suunta", + "ui.construction.bisector_hint": "Puolittaja: valitse kaksi leikkaavaa suoraa viivaa ja sitten haluttu ratkaisu", + "ui.construction.common_hint": "Yhteinen tangentti: valitse kaksi ympyrää/kaarta ja sitten haluttu ratkaisu", + "ui.construction.contact_hint": "Tangentti: valitse ympyrä/kaari tai avoin splini/kartioleikkauskäyrä ja sitten kosketuspiste (vapaamuotoiset käyrät: päätepisteet)", + "ui.construction.external_hint": "Tangentti: valitse ympyrä tai kaari, ulkopuolinen piste ja sitten haluttu ratkaisu", + "ui.construction.free_hint": "Apuviiva: napsauta kahta pistettä; aloita alusta hiiren oikealla painikkeella", + "ui.construction.point_hint": "Apuviiva: napsauta pistettä, jonka kautta viiva kulkee", + "ui.construction.reference_hint": "Apuviiva: valitse suora viiva ja sitten piste; vaihda referenssiä hiiren oikealla painikkeella", + "ui.construction.ribbon_label": "Apuviivat", + "ui.context_menu": "Pikavalikko", + "ui.continuity_g0": "G0 taite", + "ui.continuity_g1": "G1 tangentti", + "ui.continuity_g2": "G2 kaarevuus", + "ui.continuity_gap": "Rako", + "ui.copy": "Kopioi", + "ui.corner": "Kulma", + "ui.corners": "Kulmat", + "ui.cosmetic_thread_markings": "Kosmeettiset kierremerkinnät", + "ui.cosmetic_thread_markings_help": "Näytä kaavamaiset merkinnät kierteitetyissä sylinteriseinämissä. Muuttaa vain näyttöä, ei koskaan mallia tai vientejä.", + "ui.counterbore": "Upotus", + "ui.countersink": "Kartioupotus", + "ui.create": "Luo", + "ui.create_copy": "Luo kopio", + "ui.create_sketch": "Luo luonnos", + "ui.current": "nykyinen", + "ui.curvature": "Kaarevuus", + "ui.curvature_g2": "Kaarevuus (G2)", + "ui.curves": "Käyrät", + "ui.custom_program": "Mukautettu…", + "ui.custom_program_tip": "Valitse luetteloon lisättävä viipalointiohjelma", + "ui.customizeToolbar": "Mukauta työkaluriviä…", + "ui.cut": "Leikkaa", + "ui.cylinder": "Sylinteri", + "ui.dark": "Tumma", + "ui.default_modelling_orientation": "Oletusmallinnussuunta", + "ui.delete": "Poista", + "ui.depth": "Syvyys", + "ui.design": "Suunnittelu", + "ui.design_properties.author": "Tekijä", + "ui.design_properties.description": "Kuvaus", + "ui.design_properties.exports": "STEP-, IGES- ja 3MF-viennit sisältävät nämä, jos muodossa on niille paikka.", + "ui.design_properties.license": "Lisenssi", + "ui.design_properties.not_text": "Tallennettu muuna kuin tekstinä; tähän kirjoittaminen korvaa sen.", + "ui.design_properties.organization": "Organisaatio", + "ui.design_properties.others": "Muiden työkalujen tiedot", + "ui.design_properties.others_note": "Säilytetään mallissa sellaisenaan eikä viedä koskaan.", + "ui.design_properties.part_number": "Osanumero", + "ui.design_properties.project": "Projekti", + "ui.design_properties.revision": "Revisio", + "ui.design_saved": "Tallennettu {path}", + "ui.diameter": "Halkaisija", + "ui.dimension": "Mitta", + "ui.direct_interaction": "Suora vuorovaikutus", + "ui.direct_interaction_note": "Työskentele mallin parissa suoraan näkymäikkunassa. Paina tasaista pintaa tai profiilia ja vedä: ylöspäin pursottaa, alas kappaleeseen leikkaa, ja sivuttaisliike asettaa askelkoon", + "ui.direction": "Suunta", + "ui.display_quality": "Näytön laatu", + "ui.display_quality_coarse": "Karkea", + "ui.display_quality_fine": "Tarkka", + "ui.display_quality_normal": "Normaali", + "ui.display_quality_ultra": "Erittäin tarkka", + "ui.distance": "Etäisyys", + "ui.distance_1": "Etäisyys 1", + "ui.distance_2": "Etäisyys 2", + "ui.document_settings": "Asiakirjan asetukset", + "ui.don_t_save": "Älä tallenna", + "ui.draft": "Päästö", + "ui.drawing": "Piirustus", + "ui.drill_point": "Poran kärki", + "ui.easyprint_consent": "EasyPrint on Printablesin selaimessa toimiva viipalointiohjelma. Avatakseen mallin siellä SolidCraft lataa sen Printablesin palvelimille. Linkki vanhenee 30 minuutin kuluttua, ja sinun on oltava kirjautuneena Printablesiin. Jatketaanko?", + "ui.easyprint_failed": "EasyPrint-lataus epäonnistui: {error}", + "ui.easyprint_opened": "Avattu EasyPrintissä", + "ui.easyprint_row": "EasyPrint (Printables, selaimessa)", + "ui.easyprint_uploading": "Ladataan Printablesiin…", + "ui.edit": "Muokkaa", + "ui.editJointLimits": "Muokkaa nivelen rajoja", + "ui.edit_in_place": "Muokkaa paikallaan", + "ui.edit_in_place_after_turn": "Muokkaus päättyy, kun agentin vuoro päättyy", + "ui.edit_in_place_agent_busy": "Agentti työskentelee yhä tässä mallissa; yritä uudelleen, kun sen vuoro on päättynyt", + "ui.edit_in_place_already_queued": "Muokkaus odottaa jo agentin vuoron päättymistä", + "ui.edit_in_place_assembly_closed": "{name} ei ole enää auki", + "ui.edit_in_place_banner": "Muokataan kohteessa {name}", + "ui.edit_in_place_busy": "Viimeistele tai peruuta ensin auki oleva toiminto", + "ui.edit_in_place_none": "Mitään linkitettyä mallia ei muokata paikallaan", + "ui.edit_in_place_not_saved": "Osassa on tallentamattomia muutoksia; tallenna se lopettaaksesi muokkauksen", + "ui.edit_in_place_open_elsewhere": "{name} on auki toisessa SolidCraftissa", + "ui.edit_in_place_opens_after_turn": "Osa avautuu, kun agentin vuoro päättyy", + "ui.edit_in_place_refresh_dropped": "Kokoonpano muuttui Päivitä linkit -toiminnon aikana; suorita se uudelleen", + "ui.edit_in_place_refresh_stopped": "Päivitä linkit pysähtyi ennen kuin se oli valmis", + "ui.edit_in_place_refreshing": "Päivitetään linkkejä kohteessa {name}…", + "ui.edit_in_place_save_first": "Tallenna kokoonpano ensin: sen linkitetyt mallit haetaan sen tiedostosta", + "ui.edit_in_place_save_waits": "Päivitä linkit on käynnissä kohteessa {name}; tallenna se, kun se on valmis", + "ui.edit_in_place_saved_elsewhere": "Osa tallennettiin toiseen tiedostoon; kokoonpano linkittää yhä kohteeseen {name}", + "ui.edit_in_place_wait": "Odota, kunnes mallissa käynnissä oleva toiminto on valmis", + "ui.edit_section_analysis": "Muokkaa leikkausanalyysiä", + "ui.ellipse": "Ellipsi", + "ui.emboss_wrap_note": "Yhtä tuettua sylinteriä sivuavat kohtisuorat viivaprofiilit kiedotaan automaattisesti. Valitse seinämä valitaksesi kohteen.", + "ui.end_radius": "Loppusäde", + "ui.end_scale": "Loppuskaala", + "ui.entities": "Entiteetit", + "ui.equal": "Yhtä suuri", + "ui.equation_end": "Loppu", + "ui.equation_explicit": "Eksplisiittinen", + "ui.equation_form": "Muoto", + "ui.equation_note": "Muuttuja on pelkkä luku: sin(t) tulkitsee sen radiaaneina. Tulos ilman yksikköä on mallin yksikössä.", + "ui.equation_parametric": "Parametrinen", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", + "ui.equivalent_radius": "Vastaava säde", + "ui.expand": "Laajenna", + "ui.exportDxf": "Vie DXF…", + "ui.export_ellipsis_keymap": "Vie…", + "ui.export_keymap_tip": "Näppäimet, S-ruudun suosikit ja työkalurivi tiedostona", + "ui.extend": "Jatka", + "ui.extent_type": "Ulottuman tyyppi", + "ui.extract_component": "Erota tiedostoon…", + "ui.extract_design_changed": "Malli muuttui tiedostoa valittaessa. Aloita Erota tiedostoon uudelleen.", + "ui.extract_done": "Komponentti on nyt oma mallinsa, linkitettynä tähän.", + "ui.extrude": "Pursotus", + "ui.extrude_auto_to_object": "Pursota valittuun kohteeseen", + "ui.extrude_auto_to_object_tip": "Pursotuksessa, kun profiili on valittu, sen tason ulkopuolisen pinnan, kärkipisteen tai yhdensuuntaisen tason valitseminen vaihtaa ulottumaksi Kohteeseen", + "ui.factor": "Kerroin", + "ui.feature_history": "Piirrehistoria", + "ui.feature_options": "Piirteen asetukset", + "ui.features": "Piirteet", + "ui.file": "Tiedosto", + "ui.fillet": "Pyöristys", + "ui.filter_commands_or_keys": "Suodata komentoja tai näppäimiä", + "ui.findInTimeline": "Etsi aikajanalta", + "ui.findInWindow": "Etsi ikkunasta", + "ui.fine": "Tarkka", + "ui.finest": "Tarkin", + "ui.finish_sketch": "Lopeta luonnos", + "ui.fit": "Sovita", + "ui.fix_unfix": "Kiinnitä/vapauta", + "ui.flat": "Tasainen", + "ui.flip": "Käännä", + "ui.format": "Muoto", + "ui.frame_rate": "Kuvataajuus", + "ui.frame_rate_details": "Kuvataajuuden tiedot", + "ui.front": "Edestä", + "ui.fully_constrained": "Täysin rajoitettu", + "ui.function": "Funktio", + "ui.general": "Yleiset", + "ui.gpu_memory_lowered": "Näytönohjaimen muisti on lopussa: näkymä piirretään heikommalla laadulla.", + "ui.graphics": "Grafiikka", + "ui.graphics_custom": "Mukautettu", + "ui.graphics_performance": "Suorituskyky", + "ui.graphics_preset": "Grafiikan esiasetus", + "ui.graphics_preset_help": "Laatu: ympäristön peittovarjostus, näytön laatu Tarkka ja mukautuva 2× ylinäytteistys. Suorituskyky: ei ympäristön peittovarjostusta, näytön laatu Normaali ja ylinäytteistys pois. Käsin tehdyt muutokset näkyvät Mukautettuna. MSAA pysyy ennallaan.", + "ui.graphics_quality": "Laatu", + "ui.grid_follows_view": "Ruudukko seuraa näkymää", + "ui.grid_follows_view_hint": "Sivunäkymät näyttävät ruudukon niitä kohti olevalla pystytasolla maatason sijaan", + "ui.ground_shadow": "Maavarjo", + "ui.groupSelected": "Ryhmitä valitut", + "ui.hatch": "Viivoitus", + "ui.heading_add_ins": "LISÄOSAT", + "ui.heading_assemble": "KOKOA", + "ui.heading_box": "LAATIKKO", + "ui.heading_chamfer": "VIISTE", + "ui.heading_circular_pattern": "YMPYRÄKUVIO", + "ui.heading_coil": "KIERUKKA", + "ui.heading_combine": "YHDISTÄ", + "ui.heading_cone": "KARTIO", + "ui.heading_configure": "KONFIGUROI", + "ui.heading_constraints": "RAJOITTEET", + "ui.heading_construct": "RAKENNA", + "ui.heading_create": "LUO", + "ui.heading_create_sketch": "LUO LUONNOS", + "ui.heading_cylinder": "SYLINTERI", + "ui.heading_design": "SUUNNITTELU", + "ui.heading_draft": "PÄÄSTÖ", + "ui.heading_edit": "MUOKKAA", + "ui.heading_equation_curve": "YHTÄLÖKÄYRÄ", + "ui.heading_extrude": "PURSOTUS", + "ui.heading_fillet": "PYÖRISTYS", + "ui.heading_finish_editing": "LOPETA MUOKKAUS", + "ui.heading_finish_sketch": "LOPETA LUONNOS", + "ui.heading_form": "MUOTO", + "ui.heading_front": "EDESTÄ", + "ui.heading_hole": "REIKÄ", + "ui.heading_home": "ALKU", + "ui.heading_insert": "LISÄÄ", + "ui.heading_inspect": "TARKASTELE", + "ui.heading_interference": "PÄÄLLEKKÄISYYS", + "ui.heading_loft": "LOFTTAUS", + "ui.heading_make": "VALMISTA", + "ui.heading_measure": "MITTAA", + "ui.heading_mesh": "VERKKO", + "ui.heading_mirror": "PEILAUS", + "ui.heading_modify": "MUOKKAA", + "ui.heading_move": "SIIRRÄ", + "ui.heading_move_copy": "SIIRRÄ/KOPIOI", + "ui.heading_offset": "SIIRTYMÄ", + "ui.heading_offset_plane": "SIIRTYMÄTASO", + "ui.heading_ok": "OK", + "ui.heading_parameters": "PARAMETRIT", + "ui.heading_pattern": "KUVIO", + "ui.heading_pattern_on_path": "KUVIO POLULLA", + "ui.heading_pipe": "PUTKI", + "ui.heading_plane_at_angle": "TASO KULMASSA", + "ui.heading_plastic": "MUOVI", + "ui.heading_position": "SIJAINTI", + "ui.heading_prepare": "VALMISTELE", + "ui.heading_rectangular_pattern": "SUORAKULMAINEN KUVIO", + "ui.heading_revolve": "PYÖRÄYTYS", + "ui.heading_right": "OIKEALTA", + "ui.heading_save_mesh": "TALLENNA VERKKONA", + "ui.heading_scale": "SKAALAA", + "ui.heading_section_analysis": "LEIKKAUSANALYYSI", + "ui.heading_select": "VALITSE", + "ui.heading_setup": "ASETUS", + "ui.heading_sheet_metal": "OHUTLEVY", + "ui.heading_shell": "KUORI", + "ui.heading_sketch": "LUONNOS", + "ui.heading_sketch_scale": "LUONNOKSEN SKAALAUS", + "ui.heading_solid": "KIINTEÄ", + "ui.heading_sphere": "PALLO", + "ui.heading_split_body": "JAA KAPPALE", + "ui.heading_surface": "PINTA", + "ui.heading_sweep": "PYYHKÄISY", + "ui.heading_thread": "KIERRE", + "ui.heading_top": "YLHÄÄLTÄ", + "ui.heading_torus": "TORUS", + "ui.heading_utilities": "APUVÄLINEET", + "ui.heading_utility": "APUVÄLINE", + "ui.heading_weldments": "HITSAUSRAKENTEET", + "ui.height": "Korkeus", + "ui.help": "Ohje", + "ui.hide": "Piilota", + "ui.hideAxes": "Piilota akselit", + "ui.hideDimension": "Piilota mitta", + "ui.hideOrigin": "Piilota origo", + "ui.hidePlanes": "Piilota tasot", + "ui.hideProfile": "Piilota profiili", + "ui.hide_origin_by_default": "Piilota origo oletuksena", + "ui.hide_origin_note": "Uusissa malleissa Origo-kansio on aluksi piilotettu. Malli säilyttää oman Origo-asetuksensa, kun näytät tai piilotat sen.", + "ui.history_back": "Siirry askel taaksepäin", + "ui.history_below_browser": "Selaimen alla", + "ui.history_edge_hint": "kaksoisnapsauta reunaa", + "ui.history_first": "Siirry alkuun", + "ui.history_forward": "Siirry askel eteenpäin", + "ui.history_hide_names": "Piilota nimet", + "ui.history_last": "Siirry loppuun", + "ui.history_marker_tip": "Historiamerkki: palauta malli vetämällä taaksepäin; uudet piirteet tulevat tähän", + "ui.history_names": "Nimet", + "ui.history_play": "Toista loppuun", + "ui.history_play_button_hide": "Piilota toistopainike", + "ui.history_play_button_show": "Näytä toistopainike", + "ui.history_play_finish_action": "Lopeta nykyinen toiminto ennen historian toistamista", + "ui.history_play_roll_back": "Siirrä historiamerkkiä taaksepäin ennen toistamista", + "ui.history_show_names": "Näytä nimet", + "ui.history_sketch_listed_under": "Kohteen {feature} alla", + "ui.history_title": "Historia", + "ui.history_toggle_names": "Näytä tai piilota piirteiden nimet", + "ui.hole": "Reikä", + "ui.hole_tap_type": "Reiän kierteitys", + "ui.hole_type": "Reiän tyyppi", + "ui.home": "Alkuun", + "ui.homeView": "Alkunäkymä", + "ui.horizontal_constraint": "Vaakasuora-rajoite", + "ui.horizontal_vertical": "Vaakasuora/pystysuora", + "ui.import_ellipsis": "Tuo…", + "ui.infinite": "Ääretön", + "ui.insert": "Lisää", + "ui.insert_design.browse": "Selaa…", + "ui.insert_design.open_designs": "Avoimet mallit", + "ui.insert_design.saved_file": "Mallissa {name} on tallentamattomia muutoksia, joten sen tallennettu tiedosto lisättiin. Tallenna se ja tuo muutokset valitsemalla Päivitä linkit.", + "ui.insert_design.unsaved": "Tallentamattomia muutoksia", + "ui.inspect": "Tarkastele", + "ui.intersect": "Leikkaus", + "ui.isolate": "Eristä", + "ui.join": "Yhdistä", + "ui.joint_play": "▶ Toista", + "ui.joint_stop": "■ Pysäytä", + "ui.joints": "Nivelet", + "ui.keyboard_shortcuts": "Pikanäppäimet", + "ui.language": "Kieli", + "ui.layout_compact": "Tiivis", + "ui.layout_floating": "Kelluva", + "ui.length": "Pituus", + "ui.length_decimals": "Pituuden desimaalit", + "ui.light": "Vaalea", + "ui.line": "Viiva", + "ui.link_copies": "Linkitä identtiset kopiot", + "ui.link_copies_keep": "Säilytä", + "ui.link_copies_link": "Linkitä", + "ui.link_copies_text": "Nämä komponentit muodostavat samat kappaleet kuin {name}. Muutetaanko ne sen linkin sijoituksiksi?", + "ui.lock": "Lukitse", + "ui.loft": "Lofttaus", + "ui.look_at": "Katso kohti", + "ui.look_at_anim_desc": "Akselinäkymä, Katso kohti, Alkuun ja Sovita", + "ui.major_radius": "Suuri säde", + "ui.manipulator_free_alt": "Manipulaattorin vapaa veto myös Alt/Option-näppäimellä", + "ui.manipulator_free_alt_tip": "Päällä: Alt/Option tai Ctrl/Cmd pursotuksen / manipulaattorin nuolissa poistaa arvojen tartunnan. Pois: vain Ctrl/Cmd.", + "ui.manufacture": "Valmistus", + "ui.material_review.axes": "Materiaalin omat akselit; kappaleen suuntaa ei ole määritetty.", + "ui.material_review.claims": "Lähteet ja lisenssit ovat tarkistamattomia väitteitä. Puuttuvat arvot jäävät tuntemattomiksi.", + "ui.material_review.compressive_strength": "Puristuslujuus", + "ui.material_review.conditions": "Ilmoitetut olosuhteet", + "ui.material_review.density": "Tiheys", + "ui.material_review.derived": "Johdetut isotrooppiset moduulit", + "ui.material_review.elasticity": "Kimmoisuus", + "ui.material_review.input": "Liitä schema-1-materiaalikortti (JSON)", + "ui.material_review.limit": "Kortti ylittää 256 KiB:n syöterajan.", + "ui.material_review.moisture_unit": "kg vettä / kg kuivaa materiaalia", + "ui.material_review.properties": "Ominaisuudet", + "ui.material_review.review": "Tarkista kortti", + "ui.material_review.scope": "Vain ehdokas: ei määritetty eikä tallennettu. Numeerinen validointi ei ole teknistä sertifiointia.", + "ui.material_review.shear_strength": "Leikkauslujuus", + "ui.material_review.source": "Lähde", + "ui.material_review.specific_heat": "Ominaislämpökapasiteetti", + "ui.material_review.tensile_strength": "Vetolujuus", + "ui.material_review.thermal_conductivity": "Lämmönjohtavuus", + "ui.material_review.thermal_expansion": "Lämpölaajeneminen", + "ui.material_review.title": "Tarkista materiaalin fysikaaliset tiedot", + "ui.material_review.unknown": "Tuntematon", + "ui.material_review.yield_strength": "Myötölujuus", + "ui.measure": "Mittaa", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF pintatunnisteilla", + "ui.mesh_format_stl": "STL", + "ui.minor_radius": "Pieni säde", + "ui.mirror": "Peilaus", + "ui.mirror_line": "Peilausviiva", + "ui.modifiers_either": "{first} tai {second}", + "ui.move": "Siirrä", + "ui.multiple": "Useita", + "ui.named_views": "Nimetyt näkymät", + "ui.nav_bridge": "Navigoi tämän tietokoneen laitesovelluksella", + "ui.nav_bridge_connected": "Yhdistetty", + "ui.nav_bridge_port": "Portti", + "ui.nav_bridge_tip": "3D-syöttölaitteen oma ohjelma (esimerkiksi itse tehdyn 3D-hiiren ajuri) liikuttaa näkymää paikallisen yhteyden kautta, joten hiiren osoitin pysyy sinun käytössäsi. SolidCraft muodostaa yhteyden siihen tässä portissa vain tällä tietokoneella.", + "ui.nav_bridge_waiting": "Odotetaan laitesovellusta", + "ui.navigation": "Navigointi", + "ui.needs_design": "{command}: avaa ensin malli", + "ui.newSketchGroup": "Uusi luonnosryhmä", + "ui.new_body": "Uusi kappale", + "ui.new_design": "Uusi malli", + "ui.no_geometry_yet": "Ei vielä geometriaa", + "ui.no_slicer_found": "Viipalointiohjelmaa ei löytynyt", + "ui.normal": "Normaali", + "ui.normalConstruction": "Normaali / apugeometria", + "ui.not_a_program": "{path} ei ole käynnistettävä ohjelma", + "ui.object_type": "Objektin tyyppi", + "ui.objects": "Objektit", + "ui.offer_to_stand_up_y_up_files": "Tarjoa Y ylös -tiedostojen pystyttämistä", + "ui.offset": "Siirtymä", + "ui.offset_plane": "Siirtymätaso", + "ui.one_side": "Yksi puoli", + "ui.open": "Avaa", + "ui.open_ends_tip": "Ei kosketa mitään", + "ui.open_linked_design": "Avaa linkitetty malli", + "ui.open_preferences": "Avaa asetukset", + "ui.open_recent": "Avaa viimeaikainen", + "ui.operation": "Toiminto", + "ui.options": "Valinnat", + "ui.orbit": "Kierrä", + "ui.orbit_around": "Kierrä kohteen ympäri", + "ui.orientation": "Suunta", + "ui.origin": "Origo", + "ui.palette_construction_mode": "Apugeometriatila (X)", + "ui.palette_more": "Lisää", + "ui.palette_sketch_options": "Luonnoksen valinnat", + "ui.pan": "Panoroi", + "ui.pan_zoom_orbit": "Panoroi, zoomaa, kierrä", + "ui.parallel": "Yhdensuuntainen", + "ui.parameters": "Parametrit", + "ui.part_size_needs_a_part": "Valitse lisätty standardiosa (ruuvi, mutteri, aluslevy tai tappi) muuttaaksesi sen kokoa. Jos haluat suurentaa tai pienentää kappaletta, käytä Skaalaa-toimintoa.", + "ui.part_size_shared": "Rakentaa osan uudelleen ja korvaa lisätyt piirteet. Kaikki tämän komponentin esiintymät päivittyvät.", + "ui.paste": "Liitä", + "ui.path": "Polku", + "ui.pattern_on_path": "Kuvio polulla", + "ui.perforate_margin_default": "halkaisijasta", + "ui.perforate_pitch": "Jako", + "ui.perforate_square": "Neliö", + "ui.perforate_staggered": "Lomitettu (60°)", + "ui.perforate_title": "REI'ITÄ", + "ui.perpendicular": "Kohtisuora", + "ui.perspective_view": "Perspektiivinäkymä", + "ui.pinToShortcuts": "Kiinnitä pikavalintoihin", + "ui.pinToToolbar": "Kiinnitä työkaluriville", + "ui.pipe": "Putki", + "ui.pitch": "Nousu", + "ui.placement": "Sijoitus", + "ui.placement_handles": "Sijoituskahvat", + "ui.placement_handles_tip": "Kahvat tuen, tapin tai napsautusliitoksen esikatselussa: siirrä merkkiä mallin pinnalla vetämällä ja käännä sitä renkaasta", + "ui.plane": "Taso", + "ui.plane_at_angle": "Taso kulmassa", + "ui.plugins": "Laajennukset", + "ui.plugins_desktop": "Laajennukset ovat käytettävissä työpöytäsovelluksessa.", + "ui.plugins_empty": "Laajennuksia ei ole asennettu.", + "ui.plugins_none": "Mikään laajennus ei vastaa hakua.", + "ui.plugins_note": "Jokainen laajennus on kansio sijainnissa {path}, ja siinä on plugin.json. SolidCraft lukee tämän tiedoston eikä suorita kansiosta mitään koodia. Kun laajennus poistetaan käytöstä, se pysyy asennettuna.", + "ui.plugins_search": "Hae laajennuksia", + "ui.plugins_skipped": "{count} kansiota ei voitu lukea.", + "ui.plugins_version": "{version}", + "ui.point": "Piste", + "ui.point_cloud_add_filter": "Lisää suodatin…", + "ui.point_cloud_auto_range": "Automaattinen alue", + "ui.point_cloud_color_by": "Väritysperuste", + "ui.point_cloud_file_colors": "Tiedoston värit", + "ui.point_cloud_filter_by": "Suodatusperuste", + "ui.point_cloud_height_high": "Korkein", + "ui.point_cloud_height_low": "Matalin", + "ui.point_cloud_legend_filter": "Näytetty ja piilotettu yllä olevassa selitteessä", + "ui.point_cloud_no_file_colors": "Tiedostossa ei ole värejä", + "ui.point_cloud_pause": "Tauko", + "ui.point_cloud_play": "Toista", + "ui.point_cloud_single_color": "Yksi väri", + "ui.point_cloud_size": "Pisteen koko", + "ui.point_cloud_units": "Tiedoston yksiköt", + "ui.point_under_the_cursor": "Osoittimen alla oleva piste", + "ui.points": "Pisteet", + "ui.polygon": "Monikulmio", + "ui.preferences": "Asetukset", + "ui.press_a_key": "paina näppäintä…", + "ui.print_face_cleared": "{body}: tulostuspinta poistettu", + "ui.print_face_input": "Tulostuspinta", + "ui.print_face_one_body": "Tulostuspinta on valittu yhdelle kappaleelle: lähetä kappale yksinään muuttaaksesi sitä", + "ui.print_face_pick": "Valitse kiinteän kappaleen yksi tasainen pinta tulostuspinnaksi", + "ui.print_face_set": "{body}: tulostuspinta asetettu; Lähetä viipalointiohjelmaan asettaa kappaleen sen varaan", + "ui.profile": "Profiili", + "ui.profile_plane": "Profiilin taso", + "ui.profiles": "Profiilit", + "ui.project": "Projisoi", + "ui.project_face_edges_sketch": "Projisoi pinnan särmät uusiin luonnoksiin", + "ui.properties": "Ominaisuudet", + "ui.properties.copy_matrix": "Kopioi matriisi", + "ui.properties.first_moments": "Ensimmäiset momentit", + "ui.properties.inertia": "Hitausmomentti", + "ui.properties.inertia_note": "Massakeskipisteen suhteen, maailman akseleissa (g·{unit}²)", + "ui.properties.inertia_unavailable": "Ei käytettävissä: kappaleen verkko ei ole suljettu.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mitataan…", + "ui.properties.measuring_failed": "Mittaus pysähtyi ennen valmistumista.", + "ui.properties.principal_axis": "Pääakseli {number}", + "ui.properties.principal_moments": "Päähitausmomentit", + "ui.pull_direction": "Vetosuunta", + "ui.quality": "Laatu", + "ui.quantity": "Määrä", + "ui.quantity_2": "Määrä 2", + "ui.quit": "Lopeta", + "ui.radial_context_menu": "Säteittäinen pikavalikko", + "ui.radius": "Säde", + "ui.rail": "Johde", + "ui.rail_fit": "Johteen sovitus", + "ui.reachable": "Saavutettavissa", + "ui.rectangle": "Suorakulmio", + "ui.rectangular_pattern": "Suorakulmainen kuvio", + "ui.redo": "Tee uudelleen", + "ui.reference_1": "Viite 1", + "ui.reference_2": "Viite 2", + "ui.reference_distance": "Viite-etäisyys", + "ui.relink_design_changed": "Kokoonpano muuttui lähdettä valittaessa. Aloita Linkitä lähde uudelleen alusta.", + "ui.relink_done": "Lähde linkitetty uudelleen kaikille esiintymille.", + "ui.relink_not_linked": "Tätä komponenttia ei ole linkitetty lähdemalliin.", + "ui.relink_select_one": "Valitse ensin yksi linkitetty komponentti tai jokin sen esiintymistä.", + "ui.relink_source_all": "Linkitä lähde uudelleen (kaikki esiintymät)…", + "ui.removeFromToolbar": "Poista työkaluriviltä", + "ui.removeSection": "Poista leikkaus", + "ui.render": "Renderöi", + "ui.renderer": "Renderöijä", + "ui.repeat": "Toista", + "ui.repeat_nothing": "Ei vielä toistettavaa: käynnistä ensin työkalu tai valintaikkuna.", + "ui.replace_with_link_done": "Kopiot ovat nyt linkitetyn mallin sijoituksia.", + "ui.report_a_bug": "Ilmoita virheestä", + "ui.reset": "Palauta", + "ui.reset_all": "Palauta kaikki", + "ui.reset_the_settings_on_this_page": "Palauta tämän sivun asetukset", + "ui.restore": "Palauta", + "ui.restore_defaults": "Palauta oletukset", + "ui.reverse_horizontal_pan_direction": "Käännä vaakapanoroinnin suunta", + "ui.reverse_horizontal_pan_tip": "Vaakasuuntainen kahden sormen vieritys ja peukalorulla panoroivat vastakkaiseen suuntaan", + "ui.reverse_trackpad_orbit_direction": "Käännä kosketuslevyn kiertosuunta", + "ui.reverse_trackpad_orbit_tip": "Shift ja kahden sormen vieritys kiertävät vastakkaiseen suuntaan", + "ui.reverse_vertical_pan_direction": "Käännä pystypanoroinnin suunta", + "ui.reverse_vertical_pan_tip": "Pystysuuntainen kahden sormen vieritys panoroi vastakkaiseen suuntaan", + "ui.reverse_zoom_direction": "Käännä zoomaussuunta", + "ui.revolutions": "Kierrokset", + "ui.revolve": "Pyöräytys", + "ui.ribbon_exit": "Poistu", + "ui.ribbon_labels": "Näytä teksti valintanauhan kuvakkeiden alla", + "ui.ribbon_menus_skip_pinned": "Jätä kiinnitetyt komennot pois pudotusvalikoista", + "ui.ribbon_pin_footer": "Kiinnitetyt komennot näkyvät valintanauhassa. Kiinnitä rivi tai poista kiinnitys viemällä osoitin rivin päälle.", + "ui.ribbon_workspace": "Työtila", + "ui.right": "Oikealta", + "ui.save": "Tallenna", + "ui.save_as": "Tallenna nimellä", + "ui.save_mesh_no_slicer_open": "Viipalointiohjelmaa ei löytynyt: valitse Mukautettu… ja valitse viipalointiohjelmasi", + "ui.save_mesh_pick_bodies": "Valitse tallennettavat kappaleet", + "ui.saved_to": "Tallennettu {path}", + "ui.sbox_pin": "Kiinnitä S-ruudun ylälaitaan", + "ui.sbox_unpin": "Poista kiinnitys", + "ui.scale": "Skaalaus", + "ui.second_rail": "Toinen johde", + "ui.section_analysis": "Leikkausanalyysi", + "ui.section_size": "Poikkileikkauksen koko", + "ui.select": "Valitse", + "ui.selectOther": "Valitse muu…", + "ui.selectTangentChain": "Valitse tangenttiketju", + "ui.select_all": "Valitse kaikki", + "ui.selection": "Valinta", + "ui.selection_modifiers": "Valinnan muokkausnäppäimet", + "ui.selection_shift_add_only": "Shift+napsautus lisää poistamatta", + "ui.selection_shift_add_only_tip": "Päällä: Shift lisää kohteita, Ctrl/Cmd vaihtaa valintaa. Pois (oletus): sekä Shift että Ctrl/Cmd vaihtavat valintaa.", + "ui.send": "Lähetä", + "ui.send_to_slicer_unavailable": "Lähetä viipalointiohjelmaan vaatii työpöytäsovelluksen: selain ei voi käynnistää ohjelmaa", + "ui.sent_to_slicer": "Lähetetty ohjelmaan {slicer}", + "ui.sent_to_slicer_copy": "Lähetetty ohjelmaan {slicer} tiedostona {file}, koska ensimmäinen tiedosto on yhä auki siellä", + "ui.sheet_gap": "Rako", + "ui.shell": "Kuori", + "ui.shortcut_capture_add_key": "Lisää yksi tavallinen näppäin. Escape peruuttaa.", + "ui.shortcut_capture_hint": "Napsauta pikanäppäintä ja paina sitten muokkausnäppäimiä ja yhtä näppäintä. Lisää muokkausnäppäimet sekunnin sisällä; vahvista vapauttamalla kaikki näppäimet. Escape peruuttaa.", + "ui.shortcut_capture_press": "Paina muokkausnäppäimiä ja yhtä näppäintä. Escape peruuttaa.", + "ui.shortcut_capture_release": "Vahvista vapauttamalla kaikki näppäimet. Escape peruuttaa.", + "ui.shortcut_capture_settling": "Vahvistetaan pikanäppäintä… Escape peruuttaa.", + "ui.shortcut_cycles_modes": "Työkalun pikanäppäimen painaminen uudelleen vaihtaa sen tilaa", + "ui.shortcut_cycles_modes_tip": "Ennen ensimmäistä pistettä luonnostyökalun pikanäppäimen painaminen uudelleen käy läpi sen luontitilat. Pois: työkalu käynnistyy uudelleen.", + "ui.shortcut_multiple_keys": "Käytä muokkausnäppäimiä ja vain yhtä muuta näppäintä. Yritä uudelleen napsauttamalla pikanäppäintä.", + "ui.show": "Näytä", + "ui.showAll": "Näytä kaikki", + "ui.showAllBodies": "Näytä kaikki kappaleet", + "ui.showAxes": "Näytä akselit", + "ui.showDimension": "Näytä mitta", + "ui.showOrigin": "Näytä origo", + "ui.showPlanes": "Näytä tasot", + "ui.show_constraints": "Rajoitteet", + "ui.show_construction_geometries": "Apugeometriat", + "ui.show_dimensions": "Mitat", + "ui.show_profile": "Näytä profiili", + "ui.show_projected_geometries": "Projisoidut geometriat", + "ui.simple": "Yksinkertainen", + "ui.simulation": "Simulointi", + "ui.single": "Yksi", + "ui.sketch": "Luonnos", + "ui.sketch_dimension": "Luonnoksen mitta", + "ui.sketch_grid": "Luonnosruudukko", + "ui.sketch_intersect_geometry": "Geometria", + "ui.sketch_intersect_projection_link": "Projisointilinkki", + "ui.sketch_intersect_selection_filter": "Valintasuodatin", + "ui.sketch_modifiers": "Luonnoksen muokkausnäppäimet", + "ui.sketch_project_geometry": "Geometria", + "ui.sketch_report.all_constrained": "Kaikki on täysin rajoitettu.", + "ui.sketch_report.dof": "Vapausasteet", + "ui.sketch_report.loose": "Ei täysin rajoitettu", + "ui.sketch_report.status": "Tila", + "ui.sketch_report.title": "Luonnosraportti", + "ui.sketch_scale": "Luonnoksen skaalaus", + "ui.sketches": "Luonnokset", + "ui.slice": "Viipaloi", + "ui.slicer": "Viipalointiohjelma", + "ui.slicer_delete_tip": "{program}\nPoista se luettelosta painamalla Delete", + "ui.slicer_lost": "{slicer} käynnistettiin, mutta sitä ei löydetä uudelleen tiedoston lähettämiseksi. Tiedosto on tallennettu sijaintiin {path}: avaa se sieltä.", + "ui.slicer_no_file": "{slicer} on valmiina, mutta sille ei ole lähetettävää tiedostoa.", + "ui.slicer_no_response": "{slicer} ei vastannut ajoissa. Tiedosto on jo tallennettu sijaintiin {path}: lähetä se uudelleen, kun viipalointiohjelma on käynnistynyt.", + "ui.slicer_resend_hint": "Uudelleenlähetys korvaa viipalointiohjelmassa auki olevan tiedoston.", + "ui.slicer_single_instance": "Avaa tiedostot jo käynnissä olevassa viipalointiohjelmassa", + "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer ja Cura lataavat tiedoston avoinna olevaan ikkunaan sen sijaan, että käynnistäisivät uuden. Muut viipalointiohjelmat käynnistetään sellaisenaan. PrusaSlicer 3.0 toimii aina näin: sen käynnistin ei voi ottaa tiedostoa vastaan muulla tavalla.", + "ui.slicer_starting": "Käynnistetään {slicer}…", + "ui.slicer_still_starting": "Odotetaan yhä, että {slicer} käynnistyy. Mitään muuta ei lähetetty.", + "ui.slicer_stl_fallback": "3MF-tiedostoa ei voitu kirjoittaa, joten tämä on STL.", + "ui.slot": "Ura", + "ui.smooth": "Pehmeä", + "ui.smooth_wheel_zoom": "Pehmeä rullazoomaus", + "ui.smooth_wheel_zoom_tip": "Poista käytöstä, jos haluat jokaisen rullatapahtuman vaikuttavan heti ilman pehmennystä", + "ui.snap": "Tartunta", + "ui.spacing": "Väli", + "ui.spacing_2": "Väli 2", + "ui.sphere": "Pallo", + "ui.spline": "Splini", + "ui.split_body": "Jaa kappale", + "ui.stand_up_tip": "Kun STEP- tai IGES-tiedosto avautuu Z ylöspäin, tarjoa neljänneskierrosta X:n ympäri", + "ui.standard": "Vakio", + "ui.start": "Aloita", + "ui.start_over": "Aloita alusta", + "ui.start_page": "Aloitussivu", + "ui.step_values": "Muuta arvoja nuolinäppäimillä ja rullalla", + "ui.step_values_tip": "Kun kirjoitat arvokenttään, ylä- ja alanuoli muuttavat pelkkää lukua 1:llä (Shift: 10, Alt: 0.1), samoin hiiren rulla kentän päällä. Lausekkeet jätetään kirjoitetun mukaisiksi.", + "ui.stitch_tolerance_hint": "tiukka", + "ui.stretch": "Venytä", + "ui.supersampling": "Ylinäytteistys (SSAA)", + "ui.supersampling_adaptive": "Vain kun näkymä on paikallaan", + "ui.supersampling_off": "Pois (1×)", + "ui.suppress": "Ohita", + "ui.suppress_auto_snap": "Estä automaattinen tartunta piirrettäessä / vedettäessä", + "ui.suppress_auto_snap_note": "Oletus: Ctrl/Cmd tai Alt/Option estää kumpikin luonnostartunnan. Rajaa valitsemalla yksi näppäin.", + "ui.sweep": "Pyyhkäisy", + "ui.sweep_follow_direction": "Seuraa suuntaa", + "ui.sweep_keep_as_drawn": "Säilytä piirretyn mukaisena", + "ui.sweep_turn_with_path": "Käänny polun mukana", + "ui.symmetric": "Symmetrinen", + "ui.symmetry": "Symmetria", + "ui.system": "Järjestelmä", + "ui.tab_assemble": "Kokoonpano", + "ui.tab_manage": "Hallinta", + "ui.tab_sheet_metal": "Ohutlevy", + "ui.tab_view": "Näkymä", + "ui.tab_weldments": "Hitsausrakenteet", + "ui.tangent": "Tangentti", + "ui.taper_angle": "Kartiokulma", + "ui.tapped": "Kierteitetty", + "ui.text": "Teksti", + "ui.theme": "Teema", + "ui.thickness": "Paksuus", + "ui.thread": "Kierre", + "ui.tie_ends": "Sido", + "ui.tie_ends_tip": "Rajoita kaikki sitomattomat päät", + "ui.timeline_bottom": "Aikajana alhaalla", + "ui.timeline_left_note": "Pystysuuntainen historia on selaimen vasemmalla puolella. Piirteiden nimet näytetään tai piilotetaan palkista.", + "ui.timeline_left_sidebar": "Vasen sivupalkki", + "ui.tip_angle": "Kärkikulma", + "ui.to_object": "Kohteeseen", + "ui.tool_direction": "Työkalun suunta", + "ui.toolbar_tabs_hidden": "Kaikki työkalurivin välilehdet on piilotettu. Näytä jokin napsauttamalla + tai napsauttamalla tätä hiiren kakkospainikkeella.", + "ui.top": "Ylhäältä", + "ui.top_diameter": "Yläosan halkaisija", + "ui.top_radius": "Yläosan säde", + "ui.torus": "Torus", + "ui.total_angle": "Kokonaiskulma", + "ui.trackpad_pan_tip": "Kahden sormen vieritys panoroi; Fusion-tilassa Shift + kaksi sormea kiertää. Pois: vieritys zoomaa. Hiiren rulla ja nipistys zoomaavat; peukalorulla panoroi sivuttain.", + "ui.trim": "Katkaise", + "ui.turn_up_axis": "Käännä ylöspäin osoittava akseli", + "ui.twist": "Kierto", + "ui.two_finger_scroll_pans": "Kahden sormen vieritys panoroi", + "ui.two_sides": "Kaksi puolta", + "ui.ui_scale": "Käyttöliittymän skaalaus", + "ui.undo": "Kumoa", + "ui.unground": "Vapauta kiinnitys maahan", + "ui.unisolate": "Poista eristys", + "ui.units": "Yksiköt", + "ui.units_of_new_designs": "Uusien mallien yksiköt", + "ui.unlock": "Avaa lukitus", + "ui.unsuppress": "Palauta käyttöön", + "ui.untied_ends_tip": "Koskettaa vain sijainnin perusteella", + "ui.untitled": "Nimetön", + "ui.up_axis_note": "Mikä akseli osoittaa ylöspäin näkymissä (akselinäkymä, Alkuun, Katso kohti). Mallit eivät muutu.", + "ui.updateToCurrentView": "Päivitä nykyiseen näkymään", + "ui.upload": "Lataa palvelimelle", + "ui.vertical_constraint": "Pystysuora-rajoite", + "ui.view_angles": "Näkymän kulmat", + "ui.view_angles_convention": "Asteina ylöspäin osoittavan akselin ympäri. Kääntö 0° on edestä-näkymä, 90° vasemmalta, −90° oikealta, 180° takaa; kallistus 90° on ylhäältä-näkymä; positiivinen kierto kääntää mallia myötäpäivään.", + "ui.view_angles_help": "Näyttää näkymän nyökkäyksen, käännön ja kallistuksen asteina sekä projektion suuntamanipulaattorin alla. Kirjoita tarkat kulmat napsauttamalla lukemaa.", + "ui.view_angles_ortho": "Orto", + "ui.view_angles_persp": "Persp.", + "ui.view_angles_pitch": "Nyökkäys", + "ui.view_angles_readout": "Nyökkäys {pitch} · Kääntö {yaw} · Kallistus {roll}", + "ui.view_angles_roll": "Kallistus", + "ui.view_angles_yaw": "Kääntö", + "ui.view_centre": "Näkymän keskipiste", + "ui.viewport_status_clear": "Poista {name} käytöstä", + "ui.viewport_status_clear_hint": "Poista {name} käytöstä · Esc", + "ui.viewport_status_edges": "{count} särmää", + "ui.viewport_status_from": "suunnasta {direction}", + "ui.viewport_status_key": "Näytä selite: {name}", + "ui.viewport_status_minimum_radius": "Pienin säde", + "ui.viewport_status_pairs": "{count} paria", + "ui.viewport_status_section_hatch": "{setting} · viivoitus", + "ui.viewport_status_section_plane": "Nykyinen taso", + "ui.viewport_status_section_tilted": "kallistettu · {offset}", + "ui.viewport_status_stripes": "{count} raitaa", + "ui.viewport_theme_follow": "Seuraa teemaa", + "ui.visual_shaded_hidden_edges": "Varjostettu, piilosärmät näkyvissä", + "ui.visual_shaded_with_edges": "Varjostettu särmineen", + "ui.visual_style": "Visuaalinen tyyli", + "ui.visual_wireframe": "Rautalanka", + "ui.wheel_zoom_speed": "Hiiren rullan zoomausnopeus", + "ui.width": "Leveys", + "ui.window_system": "Ikkunajärjestelmä", + "ui.window_system_auto": "Automaattinen", + "ui.window_system_wayland": "Wayland", + "ui.window_system_x11": "X11", + "ui.workspace": "työtila", + "ui.workspace_animation_description": "Räjäytyskuvat, liiketutkimukset ja kuvakäsikirjoitukset", + "ui.workspace_available": "Käytettävissä", + "ui.workspace_current": "Nykyinen", + "ui.workspace_design_description": "Luonnostele, mallinna, kokoa ja tarkastele", + "ui.workspace_design_tabs": "Malli · Pinta · Kokoonpano · Lisää · Tarkastele · Hallinta · Näkymä", + "ui.workspace_drawing_description": "2D-piirustukset näkymineen, mittoineen ja nimiöineen", + "ui.workspace_footer": "Kaikki työtilat käsittelevät samaa mallia, historiaa ja valintaa; vain valintanauhan välilehdet vaihtuvat. Vaihdin on lukittu, kun luonnosta tai piirrettä muokataan.", + "ui.workspace_layout": "Työtilan asettelu", + "ui.workspace_manufacture_description": "Viipalointi, työstöradat ja asetukset", + "ui.workspace_planned": "Suunnitteilla", + "ui.workspace_render_description": "Tuotteen esikatselu, näkymäasetukset ja renderöidyt kuvat", + "ui.workspace_render_tabs": "Näkymä · Kuva", + "ui.workspace_simulation_description": "Kuormat, rajoitteet ja tulokset", + "ui.x_distance": "X-etäisyys", + "ui.y_distance": "Y-etäisyys", + "ui.y_up": "Y ylös", + "ui.z_up": "Z ylös", + "ui.zoom": "Zoomaa", + "ui.zoom_to_cursor_tip": "Rulla zoomaa osoittimen alla olevaa pistettä kohti (pois: näkymän keskipistettä kohti)", + "ui.zoom_to_the_cursor": "Zoomaa osoittimeen", + "update.available": "SolidCraft {version} on saatavilla", + "update.button": "Päivitä", + "update.button_version": "Päivitä versioon {version}", + "update.check_automatically": "Tarkista päivitykset automaattisesti", + "update.check_for_updates": "Tarkista päivitykset", + "update.check_now": "Tarkista nyt", + "update.check_unavailable": "Päivitysten tarkistus ei ole käytettävissä tässä asennuksessa.", + "update.checking": "Tarkistetaan…", + "update.copy": "Kopioi", + "update.dmg_opened": "Levykuva on avattu ja tarkistettu: vedä SolidCraft Ohjelmat-kansioon vanhan kopion tilalle ja käynnistä SolidCraft uudelleen.", + "update.download": "Lataa", + "update.download_open": "Lataa ja avaa", + "update.downloading": "Ladataan ja tarkistetaan… {done}/{total} Mt", + "update.failed": "Tarkistus epäonnistui: {error}", + "update.file": "Lataa hakee tiedoston {file}.", + "update.install_failed": "Päivitys ei asentunut: {error}. Mitään ei muutettu; Lataa hakee sen sijaan paketin.", + "update.install_msi": "Asenna (sulkee SolidCraftin)", + "update.installed": "SolidCraft {version} on asennettu. Ota se käyttöön käynnistämällä uudelleen.", + "update.installer_started": "Asennusohjelma käynnistyy, kun SolidCraft suljetaan.", + "update.later": "Muistuta myöhemmin", + "update.managed": "Päivitys saatavilla: päivitä SolidCraft ohjelmassa {manager}", + "update.no_notes": "Tällä julkaisulla ei ole julkaisutietoja.", + "update.package_hint": "Tämä kopio on peräisin lähteestä {kind}. Lataa uusi paketti ja asenna se komennolla:", + "update.prefs_dev": "Tämä on kehitysversio ({version}): se ei tarkista päivityksiä.", + "update.prefs_flatpak": "Flatpak päivittää tämän kopion ({version}): suorita {command} tai käytä ohjelmistokeskusta. SolidCraft ei tarkista päivityksiä.", + "update.prefs_label": "Päivitykset", + "update.prefs_managed": "Tämä on SolidCraft {version}, jota hallitsee {manager}. Tarkistus kysyy GitHubilta uusinta julkaisua eikä lähetä mitään muuta; päivitä SolidCraft ohjelmassa {manager}.", + "update.prefs_note": "Tämä on SolidCraft {version} ({kind}). Tarkistus kysyy GitHubilta uusinta julkaisua eikä lähetä mitään muuta.", + "update.prefs_web": "Lataa sivu uudelleen saadaksesi uusimman verkkosovelluksen. Jos verkko ei ole käytettävissä tai päivityksen lataus ei ehdi valmistua ajoissa, avautuu tallennettu versio; keskeneräinen lataus jatkuu seuraavaa uudelleenlatausta varten.", + "update.refresh_checking": "Etsitään uusinta julkaisua…", + "update.refresh_fallback": "Uudempaa julkaisua ei voitu tarkistaa; päivitetään aiemmin löydettyyn versioon {version}.", + "update.refresh_selected": "Päivitetään versioon {version}.", + "update.release_page": "Avaa julkaisusivu", + "update.restart": "Käynnistä uudelleen nyt", + "update.restart_later": "Myöhemmin", + "update.show": "Näytä päivitys versioon {version}", + "update.skip": "Ohita tämä versio", + "update.system_hint": "Tämä kopio on {kind}. Päivitä se komennolla:", + "update.title": "Päivitys saatavilla", + "update.up_to_date": "SolidCraft on ajan tasalla.", + "update.update_now": "Päivitä nyt", + "update.you_have": "Käytössäsi on {version}.", + "use a positive nominal diameter and a pitch smaller than it": "käytä positiivista nimellishalkaisijaa ja sitä pienempää nousua", + "values must be finite numbers": "arvojen on oltava äärellisiä lukuja", + "values must be numbers or expressions": "arvojen on oltava lukuja tai lausekkeita", + "view.auto_ortho_faces": "Perspektiivi kierrettäessä, ortografinen pinnoille", + "view.render_inspector": "Renderöinnin tarkastelu", + "web.files.insert_drawings_hint": "CAD-tiedostot, verkot, DXF tai SVG avoimeen malliin", + "web.files.insert_stored_drawings_hint": "Avoimeen malliin (CAD-tiedostot, verkot, DXF, SVG)", + "web.open_full": "Avaa SolidCraftissa", + "web.open_link": "Avataanko linkitetty malli?", + "web.open_link_confirm": "Muutoksia on tallentamatta. Avataanko linkitetty malli uudessa välilehdessä?", + "↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ valitse · Enter / Tab täydennä · Esc sulje", + "↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ valitse · Enter käyttää kirjoittamaasi · Esc sulje", + "⌘Enter continues on GitHub.": "⌘Enter jatkaa GitHubissa." +} diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 8ec7f80c99..12b621c6f2 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "SURFACE DE RACCORD", "BOUNDARY SURFACE": "SURFACE FRONTIÈRE", "BROWSER": "NAVIGATEUR", + "Back to the original order": "Revenir à l'ordre d'origine", "Ball": "Rotule", "Bar": "Plat", "Base Center": "Centre de la base", @@ -181,6 +182,8 @@ "Designs you open or save appear here.": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.", "Determined by To Object. Choose Angle to edit the angular extent.": "Déterminé par Jusqu'à l'objet. Choisir Angle pour modifier l'étendue angulaire.", "Diameter": "Diamètre", + "Did this assembly open the wrong way up?": "Cet assemblage s'est-il ouvert dans le mauvais sens ?", + "Did this part open the wrong way up?": "Cette pièce s'est-elle ouverte dans le mauvais sens ?", "Dimension new shapes": "Coter les nouvelles formes", "Direction": "Direction", "Discard": "Abandonner", @@ -204,6 +207,7 @@ "Edges": "Arêtes", "Edit": "Modifier", "Edit Profile Sketch": "Modifier l'esquisse du profil", + "Edit shortcuts": "Modifier les raccourcis", "Enable rule": "Activer la règle", "Enabled": "Activé", "End Radius": "Rayon final", @@ -466,6 +470,7 @@ "Radius": "Rayon", "Radius Fit": "Ajustement du rayon", "Ratio": "Rapport", + "Read-only list: shortcuts can't be changed here.": "Liste en lecture seule : les raccourcis ne peuvent pas être modifiés ici.", "Reattach selected face": "Rattacher la face sélectionnée", "Recover": "Récupérer", "Recover unsaved design?": "Récupérer la conception non enregistrée ?", @@ -591,6 +596,8 @@ "Snap to face and edge centres": "Accrocher aux centres des faces et des arêtes", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft ne peut pas soumettre directement les rapports. Soumettez-les sur GitHub ; aucun jeton n'est nécessaire dans l'application.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft s'est fermé avant l'enregistrement de ces conceptions. Leur dernier enregistrement automatique peut être ouvert.", + "Sort A to Z": "Trier de A à Z", + "Sort Z to A": "Trier de Z à A", "Spacing 2 must be a non-zero number": "L'espacement 2 doit être un nombre non nul", "Spacing must be a non-zero number": "L'espacement doit être un nombre non nul", "Specialise": "Spécialiser", @@ -599,6 +606,7 @@ "Square": "Carré", "Stand it up": "Redresser", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Redresser : un quart de tour autour de X, sous forme d'une fonction Déplacer que vous pouvez annuler.", + "Stand this assembly up?": "Redresser cet assemblage ?", "Stand this part up?": "Redresser cette pièce ?", "Start angle": "Angle de départ", "Start bulge": "Renflement de début", @@ -649,6 +657,7 @@ "Third point": "Troisième point", "This child is shared. Deleting it removes it from every copy of its parent.": "Cet enfant est partagé. Le supprimer le retire de toutes les copies de son parent.", "This child is shared. Position changes apply to every copy of its parent.": "Cet enfant est partagé. Les changements de position s'appliquent à toutes les copies de son parent.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Ce composant se trouve dans un sous-assemblage placé plusieurs fois : l'aligner le déplace dans chaque placement.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Cette image n'est pas encore jointe sur GitHub. Copiez-la ou enregistrez-la, puis joignez-la sur GitHub.", "Thread depth": "Profondeur de filetage", @@ -769,6 +778,8 @@ "at the face": "sur la face", "bom.group_children": "Sous-pièces dans la ligne de leur pièce", "browser.body_name": "Corps{number}", + "browser.calibrate_click_a": "Cliquez sur l'image pour le point A, ou saisissez ses coordonnées.", + "browser.calibrate_click_b": "Cliquez sur l'image pour le point B, ou saisissez ses coordonnées.", "browser.calibrate_title": "Calibrer : {name}", "browser.capture_many": "{count} composants ont été déplacés, mais leurs positions ne sont pas capturées.", "browser.capture_note": "Capturer la position conserve la nouvelle position ; Rétablir la position restaure la précédente.", @@ -790,6 +801,11 @@ "cam.roller": "Galet", "cam.roller_pick": "Galet (facultatif)", "cam.tip_pick": "Pointe (facultatif)", + "camera.shot_pitch": "Tangage", + "camera.shot_roll": "Roulis", + "camera.shot_yaw": "Lacet", + "canvas.decal_place_hint": "Cliquez sur une face pour placer la décalcomanie", + "canvas.place_hint": "Cliquez sur un plan ou une face pour placer le canevas", "choose a profile": "choisir un profilé", "choose the cam's and the follower's joints": "choisir les liaisons de la came et du suiveur", "choose the joints to couple": "choisir les liaisons à coupler", @@ -797,12 +813,16 @@ "click sketch curves": "cliquer sur des courbes d'esquisse", "cmd.appearance_assign": "Apparence", "cmd.appearance_chain": "Chaîne d'apparences", + "cmd.appearance_copy": "Copier l'aspect", + "cmd.appearance_favorite": "Aspect favori", "cmd.appearance_library": "Bibliothèque d'apparences", "cmd.appearance_list": "Lister les apparences", "cmd.appearance_paint": "Peindre l'apparence", + "cmd.appearance_paste": "Coller l'aspect", "cmd.appearance_pick_up": "Prélever l'apparence", "cmd.appearance_replace": "Remplacer l'apparence", "cmd.assembly_bom": "Nomenclature", + "cmd.body.print_face": "Face d’impression", "cmd.body_rename": "Renommer le corps", "cmd.browser_collapse_all": "Tout réduire", "cmd.browser_expand_all": "Tout développer", @@ -810,6 +830,7 @@ "cmd.browser_move": "Déplacer des éléments du navigateur", "cmd.browser_order": "Réordonner les éléments du navigateur", "cmd.browser_rename_group": "Renommer le groupe", + "cmd.browser_reparent": "Déplacer vers le composant", "cmd.browser_ungroup": "Dissocier", "cmd.browser_visibility": "Afficher/Masquer", "cmd.canvas_calibrate": "Étalonner le canevas", @@ -836,7 +857,9 @@ "cmd.component_metadata": "Propriétés de la pièce", "cmd.component_metadata_set": "Modifier les propriétés de la pièce", "cmd.component_move_bodies": "Déplacer les corps vers le composant", + "cmd.component_move_features": "Déplacer les fonctions vers le composant", "cmd.component_move_sketches": "Déplacer les esquisses vers le composant", + "cmd.component_paste_from_design": "Coller depuis une autre conception", "cmd.component_paste_new": "Coller comme nouveau", "cmd.component_properties": "Propriétés physiques du composant", "cmd.component_relink": "Relier à nouveau la source", @@ -973,6 +996,7 @@ "cmd.motion_trace": "Trajectoire de mouvement", "cmd.motion_trace_edit": "Modifier la trajectoire de mouvement", "cmd.motion_trace_keep": "Conserver la trajectoire de mouvement", + "cmd.occurrence_align": "Aligner l'occurrence", "cmd.occurrence_copy": "Coller (instance)", "cmd.occurrence_drag": "Faire glisser l'occurrence", "cmd.occurrence_ground": "Fixer", @@ -1030,11 +1054,17 @@ "cmd.render_library_surface_unbind": "Supprimer l'association de surface", "cmd.render_library_targets": "Trouver les cibles de la bibliothèque de rendu", "cmd.render_lights_add": "Ajouter une lumière", + "cmd.render_lights_duplicate": "Dupliquer la lumière de rendu", + "cmd.render_lights_list": "Lister les lumières de rendu", + "cmd.render_lights_mute": "Éteindre la lumière", + "cmd.render_lights_remove": "Supprimer la lumière de rendu", + "cmd.render_lights_set": "Définir la lumière de rendu", "cmd.render_lights_solo": "Lumière en solo", "cmd.render_optics_bind": "Enregistrer l'association optique", "cmd.render_optics_unbind": "Supprimer l'association optique", "cmd.render_output_size": "Définir la taille de sortie du rendu", "cmd.render_preflight": "Vérifier la demande de rendu", + "cmd.render_preflight_copy": "Copier le rapport de vérification du rendu", "cmd.render_preset_apply": "Appliquer le studio natif", "cmd.render_preset_resolve": "Examiner le studio natif", "cmd.render_quality_prepare": "Examiner le budget d'échantillonnage fixe", @@ -1057,6 +1087,13 @@ "cmd.render_start": "Rendu d'image", "cmd.render_status": "Progression du rendu", "cmd.select_clear": "Effacer la sélection", + "cmd.settings_profile_apply": "Appliquer le profil de réglages", + "cmd.settings_profile_delete": "Supprimer le profil de réglages", + "cmd.settings_profile_export": "Exporter le profil de réglages", + "cmd.settings_profile_import": "Importer un profil de réglages", + "cmd.settings_profile_list": "Profils de réglages", + "cmd.settings_profile_rename": "Renommer le profil de réglages", + "cmd.settings_profile_save": "Enregistrer le profil de réglages", "cmd.sheet_convert": "Convertir en tôlerie", "cmd.sheet_export_bend_table": "Exporter la table des plis", "cmd.sheet_export_dxf": "Exporter la mise à plat en DXF", @@ -1070,6 +1107,16 @@ "cmd.sheet_refold": "Replier", "cmd.sheet_rules": "Lister les règles de tôlerie", "cmd.sheet_unfold": "Déplier", + "cmd.sketch.construction_line": "Droite de construction", + "cmd.sketch.construction_line.angle": "Droite de construction inclinée", + "cmd.sketch.construction_line.bisector": "Bissectrice", + "cmd.sketch.construction_line.horizontal": "Droite de construction horizontale", + "cmd.sketch.construction_line.parallel": "Droite de construction parallèle", + "cmd.sketch.construction_line.perpendicular": "Droite de construction perpendiculaire", + "cmd.sketch.construction_line.tangent_at": "Tangente en un point de la courbe", + "cmd.sketch.construction_line.tangent_external": "Tangente passant par un point extérieur", + "cmd.sketch.construction_line.tangent_two": "Tangente commune", + "cmd.sketch.construction_line.vertical": "Droite de construction verticale", "cmd.sketch_arc3d": "Arc 3D", "cmd.sketch_arc_center_point": "Arc par centre", "cmd.sketch_arc_tangent": "Arc tangent", @@ -1102,6 +1149,8 @@ "cmd.sketch_drawing_bounds": "Taille du dessin", "cmd.sketch_edit": "Modifier l'esquisse", "cmd.sketch_edit_text": "Modifier le texte", + "cmd.sketch_equation_curve": "Courbe par équation", + "cmd.sketch_equation_curve_edit": "Modifier la courbe par équation", "cmd.sketch_export_dxf": "Enregistrer en DXF", "cmd.sketch_finish_auto_constrain": "Terminer avec contraintes automatiques", "cmd.sketch_fit_curves_to_section": "Ajuster des courbes à la section du maillage", @@ -1198,6 +1247,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_items": "Éléments d'état de la vue 3D", "cmd.view_ambient_occlusion": "Occlusion ambiante", "cmd.view_angle_readout": "Angles de vue", "cmd.view_apply": "Restaurer la vue nommée", @@ -1219,6 +1269,7 @@ "cmd.view_normal_to": "Vue normale à la sélection", "cmd.view_previous_tab": "Onglet précédent", "cmd.view_rename": "Renommer la vue nommée", + "cmd.view_render_frame": "Guides de cadrage du rendu", "cmd.view_right": "Vue de droite", "cmd.view_save": "Nouvelle vue nommée", "cmd.view_shots_strip": "Bande des prises de vue", @@ -1229,10 +1280,14 @@ "color.copy": "Cliquer pour copier les valeurs de couleur", "color.edit": "Cliquer pour modifier la couleur", "color.gamma": "Valeurs de couleur affichées en espace gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Saisissez #rrggbb ou #rrggbbaa ; six chiffres conservent l'opacité", + "color.hex_hint": "#rrggbb", "color.hue": "Teinte", "color.linear": "Valeurs de couleur affichées en espace linéaire 0-1", "color.saturation_value": "Saturation et valeur", "color.selected": "Couleur sélectionnée", + "config.column_filter_empty": "Aucune colonne correspondante", "contact.components_count": "{count} composants", "contact.enabled_label": "activé", "coupling.gear_1": "Engrenage 1", @@ -1277,6 +1332,7 @@ "dialog.dimension_insert_hint": "Double-cliquez sur une autre cote pilotante pour insérer son nom", "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.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.", @@ -1349,6 +1405,17 @@ "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.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.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.", + "help.sketch.construction_line.horizontal": "Crée une droite de construction horizontale infinie passant par un point. Sa contrainte horizontale est conservée lors des modifications ultérieures.", + "help.sketch.construction_line.parallel": "Sélectionnez une ligne droite de l'esquisse, puis placez une droite de construction infinie parallèle. Clic droit pour choisir une autre référence.", + "help.sketch.construction_line.perpendicular": "Sélectionnez une ligne droite de l'esquisse, puis placez une droite de construction infinie perpendiculaire. Clic droit pour choisir une autre référence.", + "help.sketch.construction_line.tangent_at": "Crée une tangente en un point d'un cercle ou d'un arc de cercle, ou à l'extrémité d'une spline ouverte ou d'une conique. Sélectionnez la courbe, puis son point de contact.", + "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.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.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.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.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.", @@ -1375,6 +1442,8 @@ "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.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.", + "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", "hole_presets.delete": "Supprimer", "hole_presets.delete_prompt": "Supprimer ce préréglage personnel ? Les perçages existants ne changent pas.", @@ -1584,6 +1653,8 @@ "nav.maya.help": "Alt+glisser gauche fait orbiter, Alt+milieu fait un panoramique, Alt+droit zoome ; glisser avec le milieu fait aussi un panoramique", "nav.openscad.help": "Glisser avec le gauche dans le vide fait orbiter, Maj+glisser gauche sélectionne par rectangle, glisser la géométrie d'esquisse la modifie ; glisser avec le droit ou le milieu fait un panoramique, Maj+droit ou Maj+milieu zoome", "nav.perspective.sketch": "La modification d'une esquisse utilise la vue orthographique. Terminer l'esquisse pour passer en perspective.", + "nav.right_drag_orbits.help": "Désactivé : glisser avec le droit ne touche pas la caméra et le bouton droit ouvre seulement le menu contextuel. Choisir l'affectation des boutons de la souris SolidWorks le désactive.", + "nav.right_drag_orbits.label": "Glisser avec le droit fait orbiter", "nav.solidworks.help": "Glisser avec le milieu fait orbiter, Ctrl+milieu fait un panoramique, Maj+milieu zoome, glisser avec le droit fait orbiter", "nav.tinkercad.help": "Glisser avec le droit fait orbiter, glisser avec le milieu ou Maj+droit fait un panoramique", "nav.touchpad.help": "Maintenir Maj et déplacer le pointeur pour faire un panoramique ; maintenir Alt/Option et le déplacer pour orbiter. Aucun bouton de souris n'est nécessaire. Le défilement vertical zoome.", @@ -1699,6 +1770,11 @@ "not a move": "pas un déplacement", "not a number": "pas un nombre", "open a sketch first": "ouvrir d'abord une esquisse", + "parameters.apply_live": "Appliquer chaque modification en quittant son champ", + "parameters.apply_live_tip": "Désactivé : les modifications attendent Appliquer ou OK, puis le modèle est reconstruit une seule fois pour toutes. Annuler ou Échap les abandonne.", + "parameters.held": "{count} modifications en attente d’Appliquer", + "parameters.held_hint": "Modifiez les expressions et les unités. Les modifications attendent Appliquer ou OK.", + "parameters.held_one": "1 modification en attente d’Appliquer", "parts.automatic_name": "automatique", "parts.dimension.across_flats": "Sur plats", "parts.dimension.bore": "Alésage", @@ -1714,12 +1790,15 @@ "parts.dimension.thickness": "Épaisseur", "parts.dimension.width": "Largeur", "parts.family.bearing": "Roulement à billes à gorge profonde (enveloppe)", + "parts.family.button_head_cap_screw": "Vis à tête bombée à six pans creux", "parts.family.dowel_pin": "Goupille cylindrique", "parts.family.hex_bolt": "Vis à tête hexagonale", "parts.family.hex_nut": "Écrou hexagonal", "parts.family.set_screw": "Vis sans tête à six pans creux, bout plat", "parts.family.socket_head_cap_screw": "Vis à tête cylindrique à six pans creux", "parts.family.washer": "Rondelle plate", + "parts.holes": "Trous (facultatif)", + "parts.placement_hint": "choisissez un ou plusieurs trous pour y loger une pièce chacun, ou OK la place à l'origine", "pattern.alignment_2": "Alignement 2", "pattern.alignment_hint": "La quantité inclut l’original. Le centrage répartit les copies de chaque côté ; si la quantité est paire, l’inversion choisit le côté qui reçoit la copie supplémentaire.", "pattern.direction_1": "Direction 1", @@ -1760,6 +1839,8 @@ "prefs.anti_aliasing_note": "Adoucit les bords crénelés.", "prefs.applies_on_restart": "Appliqué au redémarrage", "prefs.assistant": "Assistant", + "prefs.auto_operation": "Choisir l'opération automatiquement", + "prefs.auto_operation_note": "Les boîtes de dialogue des fonctions choisissent Joindre, Nouveau corps ou Couper d'après ce que touche la nouvelle matière, tant que vous n'en choisissez pas une vous-même. Désactivé : elles gardent l'opération avec laquelle elles s'ouvrent.", "prefs.axes": "Axes", "prefs.axis": "Axe", "prefs.bodies_folders": "Dossiers Corps", @@ -1770,6 +1851,8 @@ "prefs.bottom_centre": "Bas centre", "prefs.bottom_right": "Bas droite", "prefs.browser": "Navigateur", + "prefs.browser_eye_left": "Œil à côté de la flèche de dépliage", + "prefs.browser_eye_left_note": "Les lignes du navigateur affichent leur œil d'affichage et de masquage à côté de la flèche de dépliage, comme avant, au lieu d'une colonne à l'extrémité droite des lignes.", "prefs.browser_reveal_on_hover": "Révéler au survol", "prefs.browser_show_note": "Affiché garde le navigateur à l'écran. Révéler au survol le cache jusqu'à ce que le pointeur passe sur sa place ou sur le fin onglet à son bord gauche, et il reste ouvert tant que le pointeur est dedans ou qu'un de ses menus est ouvert.", "prefs.browser_shown": "Affiché", @@ -1820,6 +1903,8 @@ "prefs.graphics_card_restart": "Prend effet après le redémarrage de SolidCraft. WGPU_ADAPTER_NAME prévaut sur ce choix.", "prefs.graphics_note": "Compromis entre vitesse et fluidité. Chaque choix montre son effet sur un exemple.", "prefs.graphics_presets_note": "Les préréglages définissent ensemble les choix ci-dessous.", + "prefs.hide_constraint_glyphs_zoomed_out": "Masquer les symboles de contraintes en cas de zoom arrière", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Les symboles de contraintes quittent la vue lorsque le zoom arrière sur l'esquisse est assez important pour qu'ils l'encombrent. Désactivé : ils gardent une taille fixe à l'écran quel que soit le zoom.", "prefs.history": "Historique", "prefs.history_origin_bodies": "Afficher l'origine et les corps en haut", "prefs.history_origin_bodies_note": "L'historique à gauche ou sous le navigateur commence par le dossier Corps et l'Origine du composant actif, avec leurs yeux et leurs menus comme dans le navigateur. L'historique en bas ne change pas.", @@ -1843,6 +1928,8 @@ "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.", "prefs.left": "Gauche", "prefs.left_click": "Clic gauche", + "prefs.line_drag_draws_one": "Appuyer et glisser trace une seule ligne", + "prefs.line_drag_draws_one_note": "Dans l'outil Ligne, appuyer, faire glisser puis relâcher trace une seule ligne, du point d'appui au point de relâchement, et le tracé s'arrête là. Les clics tracent toujours une chaîne. Désactivé : un glissement place un point là où il est relâché, comme un clic.", "prefs.linked_designs": "Conceptions liées", "prefs.live_input": "Saisie en direct", "prefs.live_preview": "Aperçu en direct", @@ -1856,6 +1943,9 @@ "prefs.max_file_size_step": "Fichiers STEP et d’importation", "prefs.max_file_size_web": "L’application web ouvre les fichiers STEP et d’importation jusqu’à 512 Mo et les conceptions jusqu’à 256 Mo : un onglet du navigateur ne peut pas contenir de fichiers plus volumineux. L’application de bureau permet de relever ces limites.", "prefs.megabytes": "Mo", + "prefs.menu_grid": "Grandes icônes", + "prefs.menu_list": "Liste", + "prefs.menus": "Menus", "prefs.minutes": "minutes", "prefs.minutes_suffix": " min", "prefs.mouse": "Souris et pavé tactile", @@ -1875,14 +1965,42 @@ "prefs.origin_plane_names": "Noms des plans d'origine", "prefs.origin_plane_names_note": "Noms affichés dans le navigateur, les boîtes de dialogue et les listes. {top} est XY, {front} est XZ et {right} est YZ, avec Z vers le haut. Les commandes, les scripts et les conceptions enregistrées gardent XY, XZ et YZ.", "prefs.overall_speed": "Vitesse globale", + "prefs.parameter_list_rows": "Paramètres listés", + "prefs.parameter_list_rows_tip": "La flèche bas dans un champ de valeur vide liste les paramètres, favoris en premier, jusqu'à ce nombre. Moins allège la liste dans un gros design.", + "prefs.persistent_ribbon_pressed": "Mettre en surbrillance les commandes actives du ruban", + "prefs.persistent_ribbon_pressed_help": "Met en surbrillance les commandes du ruban tant que leurs effets sont actifs. Désactivez cette option pour conserver l'apparence neutre précédente et l'aide des commandes.", + "prefs.perspective_fov": "Champ de vision", "prefs.placement": "Placement", "prefs.prefer_free_sketch_points": "Privilégier les points d'esquisse déplaçables quand des points se superposent", "prefs.preview_choices": "Aperçu de vos choix", + "prefs.print_face_highlight": "Mise en évidence de la face d’impression", + "prefs.print_face_highlight_tip": "Intensité de la teinte et du contour de la face d’impression d’un corps dans la vue. 0 % n’affiche aucune mise en évidence.", + "prefs.profile_active": "Profil actif", + "prefs.profile_applied": "Profil appliqué.", + "prefs.profile_changed": "Les réglages ont changé depuis l'enregistrement du profil. Enregistrer les y conserve.", + "prefs.profile_deleted": "Profil supprimé.", + "prefs.profile_export": "Exporter…", + "prefs.profile_exported": "Profil exporté.", + "prefs.profile_imported": "Profil importé. Choisissez-le sous Profil actif pour basculer dessus.", + "prefs.profile_name_hint": "Nom du profil, par ex. Portable", + "prefs.profile_rename": "Renommer", + "prefs.profile_renamed": "Profil renommé.", + "prefs.profile_save": "Enregistrer", + "prefs.profile_saved": "Réglages actuels enregistrés dans le profil.", + "prefs.profile_skipped": "Profil appliqué ; {n} réglage(s) ont conservé leur valeur actuelle (voir le journal).", + "prefs.profiles": "Profils de réglages", + "prefs.profiles_note": "Raccourcis, barre d'outils, navigation, menu radial et réglages de l'interface, regroupés sous un nom et changés d'un coup.", + "prefs.project_options_dialog": "Boîte de dialogue des options de projection", + "prefs.project_options_dialog_note": "Activé : choisir Géométrie ou Corps et Lien de projection avant de projeter. Désactivé : conserver l'ancien fonctionnement par sélection directe.", "prefs.quality_default": "par défaut", "prefs.quality_fastest": "le plus rapide", "prefs.quality_slowest": "le plus lent", "prefs.quality_smoother": "plus fluide", + "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}", + "prefs.ribbon_reveal": "Montrer les résultats de recherche dans le ruban", + "prefs.ribbon_reveal_note": "Survoler un résultat dans Rechercher des commandes montre où se trouve la commande dans le ruban : son onglet, son bouton ou sa ligne dans le menu déroulant d'un panneau. Une commande lancée depuis la recherche y clignote, et une commande en cours issue d'un menu déroulant signale son panneau.", "prefs.right_click": "Clic droit", "prefs.saving": "Enregistrement automatique et récupération", "prefs.scale_note": "Également Ctrl + / Ctrl − dans la fenêtre. Les boutons font 28 px à 100 %.", @@ -1891,21 +2009,34 @@ "prefs.search_empty": "Aucun paramètre correspondant", "prefs.select": "Sélectionner", "prefs.select_one_item": "Sélectionner un élément", + "prefs.select_other_hidden": "Sélectionner la géométrie masquée avec Sélectionner un autre", + "prefs.select_other_hidden_help": "Activé : inclure les corps et les esquisses masqués ainsi que les arêtes occultées près du pointeur. Désactivé : utiliser la liste d'origine de la géométrie visible sous le pointeur.", "prefs.selecting": "Sélection", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Maj ajoute seulement ; Ctrl / Cmd bascule", "prefs.shift_add_note": "Activé : Maj ajoute, Ctrl / Cmd bascule. Désactivé (par défaut) : les deux basculent.", "prefs.shortcuts": "Raccourcis", + "prefs.single_surface_analysis": "Utiliser une seule analyse de surface", + "prefs.single_surface_analysis_help": "Désactivé, l'ombrage de surface et Continuité peuvent rester actifs ensemble. Activé, seule la dernière analyse lancée ou modifiée est conservée.", + "prefs.sketch_align_points": "S'aligner sur d'autres points d'esquisse pendant le dessin", + "prefs.sketch_align_points_note": "Pendant le dessin, un point s'aligne à la fois horizontalement sur un point d'esquisse et verticalement sur un autre, avec un guide en pointillés, et le clic conserve les deux alignements. Désactivé : uniquement sur le point précédent, un axe à la fois.", "prefs.sketch_conflict_colour": "Géométrie en conflit dans la couleur de conflit", "prefs.sketch_conflict_colour_note": "Tant que les contraintes d'une esquisse sont en conflit, les courbes et les points que retient une contrainte en échec sont aussi dessinés dans la couleur de conflit, et pas seulement son symbole ou sa cote. Désactivé : ils gardent leurs couleurs habituelles.", + "prefs.sketch_construction_lines": "Outils étendus de droites de construction", "prefs.sketch_exit_view": "À la fin d'une esquisse", "prefs.sketch_exit_view.keep": "Garder la vue actuelle", "prefs.sketch_exit_view.previous": "Revenir à la vue précédente", "prefs.sketch_exit_view.unless_rotated": "Revenir, sauf si la vue a été tournée", "prefs.sketch_exit_view_note": "Où va la vue après Terminer l'esquisse, Annuler ou un double Échap.", + "prefs.sketch_open_look_at": "Se placer face à l'esquisse à son ouverture", + "prefs.sketch_open_look_at_note": "Désactivé : créer ou modifier une esquisse laisse la vue telle quelle. Vue normale place la vue face à l'esquisse quand vous le souhaitez.", + "prefs.sketch_recent_snap": "Accrocher automatiquement aux centres et points milieux récents", + "prefs.sketch_recent_snap_note": "Pendant le dessin, le curseur s'accroche là où se croisent les guides des deux derniers centres de cercle ou points milieux de segment droit. Désactivé : ces guides restent purement visuels et le curseur ne bouge pas.", "prefs.sketch_selection": "Esquisse et sélection", "prefs.sketch_selection_heading": "Esquisse et sélection", "prefs.sketch_selection_note": "Comment les esquisses s'accrochent et comment les clics sélectionnent.", + "prefs.sketch_state_icons": "Icônes d'esquisse dans la couleur de leur état", + "prefs.sketch_state_icons_note": "Dans l'historique et le navigateur, l'icône d'une esquisse prend la couleur de son état (sous-contraint, entièrement contrainte ou en conflit) et son infobulle nomme cet état. Désactivé : toutes les esquisses ont la même icône.", "prefs.sketch_tab_always": "Toujours afficher l'onglet Esquisse", "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", @@ -1915,6 +2046,11 @@ "prefs.source": "Source", "prefs.speed": "Vitesse", "prefs.stand_up_note": "Après l'ouverture d'un fichier STEP ou IGES en Z vers le haut, proposer un quart de tour autour de X.", + "prefs.stand_up_unknown": "Pour les fichiers de programmes inconnus", + "prefs.stand_up_unknown.ask": "Demander dans une boîte de dialogue", + "prefs.stand_up_unknown.note": "Afficher une note", + "prefs.stand_up_unknown.off": "Ne pas proposer", + "prefs.stand_up_unknown_note": "Comment la proposition est faite quand l'en-tête du fichier ne nomme aucun programme connu pour enregistrer en Y vers le haut. Une note ne s'affiche que lorsque le fichier semble couché sur le dos ; Demander dans une boîte de dialogue pose la question pour chaque fichier de ce genre, comme avant.", "prefs.status_bar": "Barre d'état", "prefs.status_bar_note": "Une barre fine en bas de la fenêtre indique ce qui est en cours de modification, l'état de l'esquisse ouverte par sa couleur et les unités de la conception ; un clic sur les unités permet de les changer. L'état de l'esquisse n'apparaît alors plus dans le coin de la vue.", "prefs.status_done": "Terminé", @@ -1940,7 +2076,14 @@ "prefs.toast_place": "Notifications", "prefs.toggle_item": "Ajouter l'élément, ou le retirer à nouveau", "prefs.toolbar": "Barre d'outils", + "prefs.toolbar_tabs": "Onglets de la barre d'outils", "prefs.top": "Haut", + "prefs.trackpad_zoom_modifier": "Zoomer avec le défilement à deux doigts en maintenant", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Maintenir seulement cette touche pour zoomer vers le pointeur avec le défilement vertical. Désactivé conserve le comportement de défilement existant.", + "prefs.trackpad_zoom_modifier.off": "Désactivé", "prefs.translucent": "Translucide", "prefs.under_browser": "Sous le navigateur", "prefs.under_cursor": "Sous le curseur", @@ -1950,16 +2093,24 @@ "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.", "prefs.viewport": "Vue 3D", + "prefs.viewport_analysis_status": "Afficher l'état d'analyse de la vue 3D", + "prefs.viewport_analysis_status_help": "Afficher les analyses actives au-dessus de la vue 3D, ouvrir leur légende depuis chaque élément et utiliser Échap pour désactiver l'analyse la plus récente. Désactiver pour rétablir les légendes dans les coins et l'ancien comportement d'Échap.", "prefs.viewport_background": "Fond", "prefs.viewport_background_colour": "Couleur", "prefs.viewport_background_theme": "Dégradé du thème", "prefs.wheel": "Molette", "prefs.wheel_trackpad": "Molette et pavé tactile", "prefs.workspace": "Espace de travail", + "prefs.zoom_stop_at_model": "Arrêter le zoom au modèle", + "prefs.zoom_stop_at_model_tip": "La souris 3D et l'application du périphérique zooment vers la surface au milieu de la vue ; le zoom par glissement garde le point où le glissement a commencé. Désactivé : le zoom peut traverser le modèle. La molette et le pincement utilisent Zoom vers le curseur séparément", "prefs.zoom_toward_cursor": "Zoom, vers le curseur", "properties.title": "Propriétés : {name}", "recovery.days_ago": "il y a {count} j", "recovery.detail": "{file} · {features} entités · enregistrement auto {when}", + "recovery.discard_all": "Tout abandonner", + "recovery.discard_all_detail": "Cette action est irréversible.", + "recovery.discard_all_title": "Abandonner {count} sauvegardes automatiques ?", + "recovery.discard_all_title_one": "Abandonner 1 sauvegarde automatique ?", "recovery.hours_ago": "il y a {count} h", "recovery.just_now": "à l’instant", "recovery.min_ago": "il y a {count} min", @@ -2044,6 +2195,7 @@ "render.appearance_editor.weight": "Poids", "render.appearance_physics_separate": "L'apparence visuelle n'affecte ni masse volumique, ni données thermiques, ni autres propriétés techniques.", "render.appearances": "Apparences", + "render.assign_help": "Cliquez pour appliquer à la sélection ; faites glisser pour prévisualiser et déposer, ou cliquez sans sélection pour peindre.", "render.availability_native": "Composition native ; à examiner avant d'appliquer", "render.availability_projection": "Projection prise en charge ; voir la portée et les omissions", "render.availability_reference": "Données de référence ; pas une affectation automatique", @@ -2082,6 +2234,7 @@ "render.bloom_threshold": "Seuil des hautes lumières linéaires du halo", "render.browse_capabilities": "Inspecter le catalogue complet des capacités", "render.browse_environments": "Parcourir les environnements HDR d'origine", + "render.browse_looks": "Parcourir…", "render.browse_native_scenes": "Parcourir les studios natifs", "render.browse_physical": "Inspecter les références physiques", "render.browse_quality": "Inspecter les spécifications de qualité", @@ -2105,6 +2258,82 @@ "render.check_opaque": "Transparence : surfaces opaques uniquement ; l'alpha de l'arrière-plan est pris en charge.", "render.check_sorted_alpha": "Transparence : approximation alpha triée.", "render.check_transmission": "Transparence : transmission physique.", + "render.children.absolute_floor": "Plancher absolu", + "render.children.adaptive": "Adaptatif", + "render.children.add_look_reason": "Ajoutez un look pour modifier son activation et son intensité.", + "render.children.all_shots": "Toutes les prises de vue", + "render.children.bloom": "Halo lumineux", + "render.children.check": "Vérification", + "render.children.check_now": "Vérifier maintenant", + "render.children.chromatic": "Chromatique", + "render.children.compact": "Compact", + "render.children.contrast": "Contraste", + "render.children.copy_image": "Copier", + "render.children.copy_job": "Copier le JSON de la tâche", + "render.children.copy_request": "Copier le JSON de la requête", + "render.children.coverage_error": "Erreur de couverture", + "render.children.cpu": "Lancer de rayons CPU", + "render.children.denoise": "Réduire le bruit de l'image finale", + "render.children.depth": "Profondeur des chemins", + "render.children.distortion": "Distorsion", + "render.children.edge_samples": "Échantillons de bord", + "render.children.effects_banner": "Ces effets nécessitent le tracé de chemins GPU. Ouvrir cette section ne change jamais la méthode.", + "render.children.enable_adaptive": "Activez les pilotes indépendants pour modifier les limites adaptatives.", + "render.children.enable_bloom": "Activez le halo lumineux pour modifier son intensité, son seuil et son rayon.", + "render.children.finishing": "Finition", + "render.children.flare": "Flare", + "render.children.fresnel": "Strates de Fresnel", + "render.children.ghosts": "Anneaux fantômes", + "render.children.glass": "Transport dans le verre", + "render.children.go_to": "Aller à {editor}", + "render.children.grain": "Grain", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Conserver le master linéaire brut", + "render.children.last_result": "Dernier : {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Effets d'objectif", + "render.children.look_summary": "{tone} · {count} looks", + "render.children.looks": "Looks créatifs", + "render.children.memory": "Mémoire", + "render.children.method": "Méthode", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Nécessite le lancer de rayons CPU. Ouvrir cette section ne change jamais la méthode.", + "render.children.nested": "Diélectriques imbriqués certifiés", + "render.children.not_checked": "Non vérifié", + "render.children.path": "Tracé de chemins", + "render.children.pilots": "Échantillons pilotes", + "render.children.preview": "Charge de l'aperçu", + "render.children.progressive_reason": "Choisissez Progressif pour modifier son nombre d'échantillons et sa graine.", + "render.children.radius": "Rayon", + "render.children.reflect_depth": "Profondeur de réflexion", + "render.children.reflect_samples": "Échantillons de réflexion", + "render.children.regrade": "Réétalonnage", + "render.children.relative_error": "Erreur relative", + "render.children.render": "Rendu", + "render.children.render_shot": "Rendre la prise de vue", + "render.children.renderer": "Moteur de rendu", + "render.children.renderers": "Moteurs de rendu", + "render.children.request": "Cette requête", + "render.children.reset_finishing": "Réinitialiser la finition", + "render.children.review_quality": "Examiner une spécification de qualité…", + "render.children.rgb_gain": "Gain RVB", + "render.children.samples": "Échantillons", + "render.children.sampling": "Échantillonnage", + "render.children.saturation": "Saturation", + "render.children.scene_summary": "{bodies} corps · {triangles} triangles", + "render.children.seed": "Graine", + "render.children.sharpening": "Accentuation", + "render.children.single_image": "Image unique", + "render.children.soft_focus": "Flou artistique", + "render.children.streak": "Traînée", + "render.children.strength": "Intensité", + "render.children.studio": "Studio", + "render.children.threshold": "Seuil", + "render.children.tone": "Tons", + "render.children.under_hood": "Sous le capot", + "render.children.vignette": "Vignettage", + "render.children.workstation": "Station de travail", + "render.choose_look": "Choisissez d'abord un aspect dans Apparences", "render.chromatic_offset": "Décalage chromatique des bords", "render.clear_stage": "Supprimer le décor enregistré", "render.copy_request": "Copier le JSON", @@ -2356,8 +2585,33 @@ "render.flare_strength": "Intensité du flare des hautes lumières", "render.flare_threshold": "Seuil du flare en linéaire scène", "render.fold_browser": "Réduire le navigateur", + "render.frame_aspect": "Format", + "render.frame_centre_mark": "Repère central", + "render.frame_copy_limit": "Aucun nom disponible pour dupliquer la prise de vue", + "render.frame_copy_name": "{name} copie {number}", + "render.frame_custom": "Personnalisé", + "render.frame_distance_unit": " × taille", + "render.frame_duplicate": "Dupliquer", + "render.frame_exposure": "Exposition", + "render.frame_fov": "Champ de vision", + "render.frame_guides": "Guides", + "render.frame_invalid_aspect": "Préréglage de format non valide", + "render.frame_lens": "Champ {fov}° · ≈ {mm} mm", + "render.frame_name": "Nom", + "render.frame_output_size": "Taille de sortie", + "render.frame_photo": "3:2 · Photo", + "render.frame_portrait": "4:5 · Portrait", "render.frame_presets": "Préréglages de cadre", "render.frame_presets_pending": "Arrive avec l'éditeur de prises de vue", + "render.frame_projection": "Projection", + "render.frame_render_shot": "Rendre cette prise de vue", + "render.frame_rule_thirds": "Règle des tiers", + "render.frame_safe_area": "Zone de sécurité", + "render.frame_shot_title": "Prise de vue · {name}", + "render.frame_square": "1:1 · Carré", + "render.frame_tall": "9:16 · Vertical", + "render.frame_thirds": "Tiers", + "render.frame_wide": "16:9 · Écran large", "render.gpu_depth": "Profondeur maximale des chemins", "render.gpu_options_help": "Les échantillons, la profondeur, la graine et l'exposition sont enregistrés avec ce préréglage. Seule la méthode d'image active est enregistrée.", "render.gpu_path_failed": "Traceur de chemins GPU indisponible", @@ -2385,6 +2639,7 @@ "render.heading_results": "RÉSULTATS", "render.heading_shots": "PRISES DE VUE", "render.hide_shots_label": "Masquer la bande des prises de vue", + "render.hover_look": "{object} · {look} · cliquer pour sélectionner", "render.image_advanced": "Réglages GPU avancés", "render.image_cancelled": "Rendu annulé", "render.image_cancelling": "Annulation", @@ -2531,17 +2786,95 @@ "render.library_uniform_ack": "Utiliser l'approximation uniforme sans effets spatiaux ni avancés", "render.library_usable": "Projections exécutables", "render.library_users": "Utilisations", + "render.light_aim": "Viser", "render.light_anchor_pending": "Le changement du repère des lumières n'est pas encore pris en charge.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Choisissez le lancer de rayons CPU ou un studio GPU explicite pour modifier les lumières. Les lumières des anciens studios GPU restent en lecture seule.", + "render.light_behaviour": "COMPORTEMENT", + "render.light_default_fill": "Appoint", + "render.light_default_key": "Principale", + "render.light_default_number": "Lumière {number}", + "render.light_default_rim": "Contour", + "render.light_delete": "Supprimer", + "render.light_distance": "Distance", + "render.light_duplicate": "Dupliquer", + "render.light_elevation": "Élévation", + "render.light_gpu_punctual": "Les lumières ponctuelles et les spots sont disponibles avec le lancer de rayons CPU.", + "render.light_hard_shadow": "Cette lumière a des ombres nettes ; la douceur de l'émetteur n'est pas disponible.", + "render.light_intensity": "Intensité", + "render.light_missing": "Cette lumière n'existe plus. Sélectionnez une lumière dans le navigateur.", + "render.light_place_highlight": "Placer selon le reflet", + "render.light_place_pending": "Le placement selon le reflet nécessite la sélection des lumières dans la vue 3D et n'est pas encore pris en charge.", + "render.light_position": "POSITION", + "render.light_preview_help": "La vue en direct est sans ombres ; les lumières surfaciques utilisent une approximation par échantillon central. Le rendu utilise le modèle d'éclairage complet.", + "render.light_product_centre": "Centre du produit", + "render.light_size": "Taille", + "render.light_softness": "Douceur", + "render.light_temperature": "Température de couleur", + "render.light_temperature_help": "Teinte approximative de corps noir ; enregistrée en RVB linéaire.", + "render.light_type": "Type", + "render.light_type_area": "Surfacique", + "render.light_type_point": "Ponctuelle", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Soleil", + "render.light_view_only": "Le solo et l'extinction agissent sur cette vue et ses rendus ; ils ne sont jamais enregistrés dans la conception.", + "render.light_world_origin": "Origine du monde", + "render.lighting_aim_point": "Point visé", + "render.lighting_apply": "Appliquer le préréglage", + "render.lighting_area_samples": "Échantillons surfaciques", + "render.lighting_budget": "Budget", + "render.lighting_budget_help": "Les nombres sont en lecture seule. Ajoutez, dupliquez ou supprimez des lumières pour les modifier ; la méthode fixe les limites.", + "render.lighting_colour": "Couleur", + "render.lighting_consent": "Remplacer les lumières directionnelles par ce préréglage", + "render.lighting_custom": "Lumières personnalisées", + "render.lighting_custom_help": "Choisir Personnalisé transforme le préréglage en lumières modifiables, chacune sur sa propre ligne de l'arborescence.", + "render.lighting_direction": "Direction X Y Z", + "render.lighting_direction_help": "Ou saisissez une direction vers la lumière (X Y Z).", + "render.lighting_distance_unit": " × taille", + "render.lighting_edge_u": "Vecteur du côté U", + "render.lighting_edge_v": "Vecteur du côté V", + "render.lighting_falloff": "Atténuation", + "render.lighting_falloff_help": "L'intensité est donnée à la distance de référence.", + "render.lighting_height": "Hauteur V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Éteindre cette lumière", + "render.lighting_point": "Point", + "render.lighting_preset": "Préréglage de studio", + "render.lighting_preset_cpu": "Les préréglages de studio directionnels nécessitent le lancer de rayons CPU. L'éclairage GPU utilise des lumières Soleil et Surfacique explicites.", + "render.lighting_preset_row": "Préréglage", + "render.lighting_radiance": "Radiance", + "render.lighting_reference": "Référence", + "render.lighting_review_help": "Remplace la liste des lumières directionnelles. Conserve les lumières ponctuelles et surfaciques, l'environnement, les ombres, la caméra et tous les autres réglages de la prise de vue.", + "render.lighting_review_title": "Examiner le préréglage d'éclairage", + "render.lighting_samples_cpu": "Les échantillons de lumière surfacique s'appliquent au lancer de rayons CPU.", + "render.lighting_samples_need_area": "Ajoutez une lumière surfacique CPU pour définir les échantillons surfaciques.", + "render.lighting_samples_reason": "Les échantillons surfaciques doivent valoir 1, 4, 16 ou 64.", + "render.lighting_shadow_cpu": "L'interrupteur des ombres nécessite le lancer de rayons CPU. Le tracé de chemins GPU projette toujours des ombres.", + "render.lighting_shadows": "Projeter des ombres", + "render.lighting_shadows_section": "Ombres et échantillonnage", + "render.lighting_shape": "Forme", + "render.lighting_solo": "Mettre cette lumière en solo", + "render.lighting_source": "Source", + "render.lighting_source_section": "Source de lumière", + "render.lighting_spot_preserved": "Cette lumière ponctuelle conserve son cône de spot enregistré tant que son type n'est pas changé.", + "render.lighting_sun_distance": "La direction du soleil ne dépend ni de la distance ni du point visé.", + "render.lighting_unsupported": "non pris en charge", + "render.lighting_used": "{used} sur {limit}", + "render.lighting_width": "Largeur U", + "render.lighting_world_position": "Monde X Y Z", "render.lights_fixed_to": "Lumières fixées à", "render.linear_help": "Conserve un master RVBA en flottant 32 bits avant le débruitage, l'exposition, le mappage des tons, les filtres et l'arrière-plan. sRVB/D65 linéaire ; prémultiplié par la couverture. La mémoire supplémentaire est admise explicitement. Pas de compression ni de passes supplémentaires.", "render.linear_retain": "Conserver l'EXR linéaire brut", "render.linear_unavailable": "L'image terminée sélectionnée n'a pas d'EXR conservé. Activez la conservation avant le rendu ; un ancien PNG ne permet pas de récupérer la radiance brute.", "render.look_add": "Ajouter un look colorimétrique", + "render.look_applied": "Appliqué : {name}", "render.look_bleach": "Inspiration traitement sans blanchiment (bleach bypass)", "render.look_cool": "Froid", "render.look_cyanotype": "Inspiration cyanotype", "render.look_faded": "Tirage délavé", + "render.look_material": "Matériau", "render.look_mono": "Monochrome", + "render.look_pasted": "Aspect collé", "render.look_sepia": "Sépia", "render.look_strength": "Intensité du look", "render.look_teal_orange": "Bleu canard / orange", @@ -2617,9 +2950,11 @@ "render.output_advanced": "Avancé", "render.output_backend_ready": "Prêt", "render.output_check": "Vérifier", + "render.output_check_failed": "Vérification · erreur", "render.output_check_help": "Lancez Vérifier pour examiner cette requête sans démarrer de rendu.", "render.output_check_summary": "Vérification · Remarques : {count}", "render.output_copy": "Copier", + "render.output_copy_report": "Copier le rapport", "render.output_destination": "Destination", "render.output_destination_help": "Choisissez le fichier de destination lors de l'export de l'image terminée.", "render.output_dimensions": "Taille", @@ -2629,6 +2964,7 @@ "render.output_fit": "Cadrer", "render.output_format": "Format", "render.output_format_status": "Le PNG est l'image d'affichage terminée exacte. L'EXR GPU facultatif conserve la radiance brute FLOAT32 en linéaire scène, avant les filtres d'affichage. L'export ne substitue jamais le cache d'aperçu ni une autre tâche.", + "render.output_grading": "Étalonnage", "render.output_image": "Image", "render.output_largest": "{width} × {height} max", "render.output_lock_ratio": "Verrouiller le rapport d'aspect", @@ -2638,6 +2974,8 @@ "render.output_quality_waiting": "en attente du contrat de qualité", "render.output_ratio_refused": "La dimension liée dépasse la taille limite de cette méthode. La taille précédente est conservée ; déverrouillez le rapport ou choisissez une valeur plus petite.", "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_ok": "Taille correcte · {width} × {height} px", "render.output_swap_orientation": "Permuter largeur et hauteur", "render.output_tools_inspector": "Modifier la taille et le format dans l'inspecteur Sortie.", @@ -2654,6 +2992,7 @@ "render.page_output": "Sortie", "render.page_scene": "Scène", "render.paint_label": "Peindre", + "render.painting_with": "Peinture avec {name}", "render.passive_stage": "Sol et décor passif", "render.passive_stage_help": "Les panneaux passifs finis n'appartiennent qu'aux images rendues. Ils n'ajoutent ni corps CAO ni masse. Leur placement est figé après application ; un recoupement avec les limites du produit est refusé. Le CPU ne fournit pas d'éclairage diffus par rebond.", "render.perspective": "Caméra en perspective", @@ -2675,6 +3014,7 @@ "render.pick_help": "Choisir Corps, Face ou Composant. Cliquer dans le vide pour effacer ; Maj ou Ctrl ajoute ou inverse. Les surbrillances n'apparaissent que dans la vue produit en direct.", "render.pick_label": "Sélectionner", "render.pick_up_label": "Prélever", + "render.pick_up_prompt": "Cliquez sur un objet pour prélever son aspect", "render.pick_waiting": "La sélection de produits n'est pas encore prise en charge.", "render.post_contrast": "Contraste", "render.post_gain_b": "Gain bleu", @@ -2714,13 +3054,17 @@ "render.radial_distortion": "Distorsion radiale", "render.radial_fit": "Cadrer le produit", "render.radial_save": "Enregistrer la prise de vue", + "render.recent_looks": "Aspects récents", "render.regrade_action": "Réétalonner le master brut (sans débruitage)", "render.regrade_help": "Crée un PNG distinct à partir du master brut terminé sélectionné, avec ces réglages de présentation. Ne trace aucun rayon, ne change ni la taille, ni la caméra, ni les lumières capturées, et n'écrase pas l'EXR. Le débruitage n'est pas réappliqué : les masters bruts ne conservent pas ses guides. Le résultat reste exportable par les mêmes réglages de tâche.", "render.regrade_label": "Réétalonner", "render.reload_settings": "Recharger les réglages enregistrés", + "render.remove_override": "Retirer la surcharge", + "render.replace_all": "Tout remplacer", "render.replace_ground": "Confirmer le remplacement du décor existant", "render.replace_label": "Remplacer", "render.replace_saved_surface": "Remplacer une association enregistrée existante pour cette cible", + "render.replace_with": "Remplacer par", "render.restore_camera": "Restaurer la caméra enregistrée", "render.result_actual": "100 %", "render.result_actual_help": "Un pixel d'image par pixel d'affichage.", @@ -2772,14 +3116,28 @@ "render.saved_surface_remove": "Supprimer l'association enregistrée", "render.saved_surfaces": "Associations de surface enregistrées", "render.saved_surfaces_help": "Les textures intégrées épinglées suivent l'occurrence sélectionnée en millimètres locaux. Une cible remplacée ou manquante est une erreur, jamais une réaffectation automatique.", + "render.scene_all_shots": "Toutes les prises de vue ({count})", "render.scene_apply": "Appliquer le studio examiné", + "render.scene_batch_reason": "Le rendu de toutes les prises de vue nécessite la file de rendu des prises de vue enregistrées, qui n'est pas encore disponible.", + "render.scene_check_now": "Vérifier maintenant", + "render.scene_check_passed": "Vérification réussie · {notes} remarques", + "render.scene_check_stale": "Vérification périmée · vérifiez à nouveau", + "render.scene_check_unchecked": "Pas encore vérifié · 0 remarque", "render.scene_consent": "Confirmer le remplacement de l'éclairage, de l'environnement, de l'exposition et du décor", + "render.scene_light_summary": "{environment} · {lights} lumières", + "render.scene_look_summary": "{looks} aspects · {overrides} surcharges", + "render.scene_open_check": "Ouvrir la vérification", "render.scene_preserved_fields": "La caméra, l'échantillonnage, les dimensions, les associations optiques, la géométrie CAO et les matériaux techniques sont conservés.", "render.scene_preserves_design": "Composer un éclairage et un décor passif pour le sujet visible. La géométrie, les matériaux techniques et la caméra restent distincts.", + "render.scene_ready": "Prêt à lancer le rendu", + "render.scene_reminder": "Scène préserve la conception. La géométrie, les propriétés techniques et la CAO ne sont jamais modifiées depuis Rendu.", + "render.scene_reminders": "Rappels", + "render.scene_renderer_ready": "{renderer} prêt", "render.scene_replaces": "Remplace", "render.scene_review": "Résoudre et examiner", "render.scene_review_help": "Examinez la composition native complète avant d'appliquer. Les spécifications SCENE avancées non liées ne sont pas substituées.", "render.scene_scope_details": "Portée de la composition", + "render.scene_stages": "Étapes", "render.search_appearances": "Rechercher parmi {count} apparences", "render.setup_legacy_environment": "Ce préréglage ancien utilise une rotation ou une intensité d'environnement héritée. Ces valeurs sont conservées ; convertissez-les explicitement avant d'enregistrer une tâche d'image complète.", "render.setup_name": "Nom du préréglage", @@ -2792,7 +3150,22 @@ "render.shell_tab_camera": "CAMÉRA DE RENDU", "render.shell_tab_lighting": "ÉCLAIRAGE", "render.shell_tab_scene": "SCÈNE DE RENDU", + "render.shot_aperture": "Ouverture", + "render.shot_distance": "Distance", + "render.shot_dof": "Profondeur de champ", "render.shot_duplicate": "Dupliquer la prise de vue enregistrée", + "render.shot_edit_output": "Modifier dans Sortie", + "render.shot_exposure_reason": "L'exposition nécessite le lancer de rayons CPU ou le tracé de chemins GPU.", + "render.shot_exposure_section": "Exposition", + "render.shot_focus_distance": "Distance de mise au point", + "render.shot_fov_reason": "Le champ de vision nécessite une projection en perspective.", + "render.shot_frame_subject": "Cadrer le sujet", + "render.shot_invalid_camera": "Les valeurs de la caméra doivent être finies et dans les limites.", + "render.shot_lens": "Objectif", + "render.shot_lens_enable_reason": "Activez la profondeur de champ pour modifier l'ouverture et la distance de mise au point.", + "render.shot_lens_gpu_reason": "La profondeur de champ nécessite le tracé de chemins GPU.", + "render.shot_lens_perspective_reason": "La profondeur de champ nécessite une projection en perspective.", + "render.shot_look_selection": "Viser la sélection", "render.shot_manager": "Prises de vue enregistrées", "render.shot_new_name": "Nom de la nouvelle prise de vue", "render.shot_remove": "Supprimer la prise de vue enregistrée", @@ -2800,6 +3173,9 @@ "render.shot_remove_title": "Supprimer la prise de vue enregistrée ?", "render.shot_rename": "Renommer la prise de vue enregistrée", "render.shot_saved_only": "Enregistrez d'abord le brouillon actuel. Dupliquer et renommer conservent exactement les options enregistrées et les références aux ressources.", + "render.shot_selection_reason": "Sélectionnez un corps ou une face à viser.", + "render.shot_size": "Taille", + "render.shot_target": "Cible", "render.shots_camera_changed": "La caméra a changé depuis l'enregistrement de cette prise de vue", "render.shots_name_help": "Saisissez un nom unique pour la prise de vue actuelle.", "render.shots_no_result": "Aucun rendu pour l'instant", @@ -3022,6 +3398,13 @@ "render.wait_pick_up": "pas encore pris en charge : la sélection de produits et le prélèvement d'apparences", "render.wait_replace": "pas encore pris en charge : le remplacement de toutes les utilisations d'une apparence", "render.wait_solo": "pas encore pris en charge : la visibilité des lumières et les contrôles solo", + "render.wear_applies_to": "S'applique à", + "render.wear_layer_count": "{count} couches", + "render.wear_layer_one": "{count} couche", + "render.wear_layers": "Couches", + "render.wear_no_layers": "Aucune couche", + "render.wear_selected_layer": "Couche sélectionnée", + "render.wear_title": "Usure", "sample.bracket.about": "Tôle de base avec bords tombés et un ourlet, selon une règle de 2 mm", "sample.bracket.name": "Équerre en tôle", "sample.enclosure.about": "Boîte en coque avec lèvre, bossages de vis, appui et clip", @@ -3055,6 +3438,7 @@ "select an axis": "sélectionner un axe", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "sélectionner d'abord un axe, une arête droite ou circulaire, ou une face cylindrique, conique ou torique", "select bodies first": "sélectionner d'abord des corps", + "select components first": "sélectionner d'abord des composants", "select corner points, or the two lines of one corner": "sélectionner des points de coin, ou les deux lignes d'un coin", "select one corner point, or the two lines of one corner": "sélectionner un point de coin, ou les deux lignes d'un coin", "select sheet edges first": "sélectionner d'abord des arêtes de tôle", @@ -3073,6 +3457,7 @@ "settings.double_click_components": "Double-cliquez sur un composant pour l'activer, ou pour modifier sur place un composant lié", "settings.frame_rate_details_note": "Ajoute un graphe des temps des dernières images avec les triangles dessinés superposés, un histogramme des temps d'image et ce que le GPU a dessiné. Cliquez sur la fréquence d'images dans la vue pour les afficher ou les masquer.", "settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue.", + "settings.insert_lists_open_designs": "Insérer une conception liste d'abord les conceptions ouvertes dans d'autres onglets", "settings.measure_axis_distances": "Afficher les cotes X/Y/Z", "settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.", "settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft", @@ -3097,6 +3482,10 @@ "shortcut.unpin": "Désépingler", "sketch is missing": "l'esquisse est manquante", "sketch.degrees_of_freedom": "{count} degrés de liberté", + "sketch.dimension_added_driven": "L'esquisse détermine déjà cette cote ; elle a donc été ajoutée comme cote pilotée (de référence)", + "sketch.dimension_applied": "Cote appliquée", + "sketch.dimension_applied_defined": "Cote appliquée ; paramètre `{name}` défini", + "sketch.dimension_applied_renamed": "Cote appliquée ; paramètre nommé `{name}`", "sketch.dof_left": "{count} degrés de liberté restants", "sketch.dof_left_one": "{count} degré de liberté restant", "sketch.draw_curves": "Dessiner en {linetype}", @@ -3123,6 +3512,7 @@ "sketch_text.align_right": "Droite", "sketch_text.bold": "Gras", "sketch_text.builtin_font": "{family} (intégrée)", + "sketch_text.formula": "Commencez par = pour composer le texte avec des paramètres : ='L' + number(Largeur)", "sketch_text.italic": "Italique", "sketch_text.loading_fonts": "Recherche des polices installées…", "sketch_text.on_path": "Le texte suit sa trajectoire : la courbe le positionne", @@ -3176,8 +3566,10 @@ "timeline.move_to_group": "Déplacer les fonctions vers le dossier", "timeline.rename_group": "Renommer le dossier de fonctions", "timeline.reorder_many": "Réordonner les fonctions", + "timeline.suppress_features": "Désactiver les fonctions", "timeline.suppress_folders_tip": "Désactiver ou réactiver toutes les fonctions des dossiers et de leurs dossiers imbriqués", "timeline.ungroup": "Supprimer les dossiers de fonctions", + "timeline.unsuppress_features": "Réactiver les fonctions", "to the face": "jusqu'à la face", "to the next face": "jusqu'à la face suivante", "toolbar.customize_note": "Onglet {tab}. Cochez une commande pour lui attribuer un bouton dans la barre d’outils. Vous pouvez aussi faire glisser une ligne du menu déroulant d’un panneau vers la barre d’outils, retirer un bouton en le faisant glisser hors de la barre ou utiliser l’épingle d’une ligne du menu déroulant.", @@ -3249,6 +3641,10 @@ "ui.3d_mouse_offer": "Calibrer votre souris 3D corrige les axes échangés ou inversés et règle sa vitesse et sa sensation.", "ui.3d_mouse_offer_never": "Ne plus afficher", "ui.3d_mouse_offer_swap": "Passer à Hidraw (pilote direct)", + "ui.3d_mouse_orbit": "Orbite", + "ui.3d_mouse_orbit_classic": "Classique", + "ui.3d_mouse_orbit_tip": "Ciblée : la souris 3D fait tourner la vue autour de la partie du modèle située au milieu de la vue au début de la rotation, marquée par une boule discrète. Classique : autour du centre de la vue", + "ui.3d_mouse_orbit_traced": "Ciblée", "ui.3d_mouse_reading": "Lecture", "ui.3d_mouse_reset_axes": "Réinitialiser l'attribution des axes", "ui.3d_mouse_reset_view": "Réinitialiser la vue", @@ -3257,6 +3653,8 @@ "ui.3d_mouse_section": "Souris 3D", "ui.3d_mouse_show_in_view": "Afficher la souris 3D dans la vue 3D", "ui.3d_mouse_show_in_view_tip": "Une image de votre souris 3D dans le coin inférieur gauche, qui bouge quand vous la bougez.", + "ui.3d_mouse_sketch_orbit": "Faire pivoter les esquisses avec la souris 3D", + "ui.3d_mouse_sketch_orbit_tip": "La souris 3D fait aussi pivoter la vue quand une esquisse est ouverte et vue de face, comme la souris. Désactivé : elle déplace et zoome l'esquisse jusqu'à ce que vous incliniez la vue.", "ui.3d_mouse_skip": "Mon appareil ne peut pas le faire", "ui.3d_mouse_skip_tip": "Ignore ce mouvement dans les deux sens ; la calibration le laisse de côté.", "ui.3d_mouse_speed": "Vitesse de la souris 3D", @@ -3285,6 +3683,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.", "ui.3d_mouse_switch_cap": "Calibrer comme retour au centre", "ui.3d_mouse_switch_trackball": "Calibrer comme un trackball", + "ui.3d_mouse_use_direct": "Utiliser le pilote direct", + "ui.3d_mouse_use_direct_tip": "Lire la souris 3D directement (hidraw) au lieu de passer par spacenavd.", "ui.3d_mouse_view_axis": "Axe de la vue", "ui.3d_mouse_view_off": "Désactivé", "ui.3d_mouse_view_off_tip": "Le profil du pilote de la souris 3D désactive cet axe : son mouvement ne déplace pas la vue. Choisir ici le mouvement d'un autre axe les échange, et ce mouvement cesse de déplacer la vue.", @@ -3591,6 +3991,8 @@ "ui.cancel": "Annuler", "ui.canvases": "Canevas", "ui.center": "Centre", + "ui.center_arc_follows_cursor": "L'arc par centre suit le sens du curseur", + "ui.center_arc_follows_cursor_tip": "Un arc par centre tourne dans le sens horaire ou antihoraire selon la façon dont le curseur tourne autour de son centre. Désactivé : toujours dans le sens antihoraire depuis le début.", "ui.centerline": "Ligne de construction", "ui.chain": "Chaîne", "ui.chamfer": "Chanfrein", @@ -3599,6 +4001,7 @@ "ui.circle": "Cercle", "ui.circular_pattern": "Répétition circulaire", "ui.clear": "Effacer", + "ui.clear_print_face": "Effacer la face d’impression", "ui.clear_recent": "Effacer les récents", "ui.clear_the_selection": "Effacer la sélection", "ui.click_selects_whole_bodies": "Un clic sélectionne des corps entiers", @@ -3633,6 +4036,15 @@ "ui.constant": "Constant", "ui.constraints_conflict": "Contraintes en conflit", "ui.construction": "Construction", + "ui.construction.angle_hint": "Droite de construction : cliquez sur un point, puis saisissez un angle ou choisissez une direction", + "ui.construction.bisector_hint": "Bissectrice : sélectionnez deux lignes droites sécantes, puis la solution souhaitée", + "ui.construction.common_hint": "Tangente commune : sélectionnez deux cercles/arcs, puis la solution souhaitée", + "ui.construction.contact_hint": "Tangente : sélectionnez un cercle/arc ou une spline/conique ouverte, puis le point de contact (courbes libres : extrémités)", + "ui.construction.external_hint": "Tangente : sélectionnez un cercle ou un arc, un point extérieur, puis la solution souhaitée", + "ui.construction.free_hint": "Droite de construction : cliquez sur deux points ; clic droit pour recommencer", + "ui.construction.point_hint": "Droite de construction : cliquez sur le point de passage", + "ui.construction.reference_hint": "Droite de construction : sélectionnez une ligne droite, puis un point ; clic droit pour changer la référence", + "ui.construction.ribbon_label": "Lignes aux.", "ui.context_menu": "Menu contextuel", "ui.continuity_g0": "G0 angle vif", "ui.continuity_g1": "G1 tangence", @@ -3724,6 +4136,14 @@ "ui.end_scale": "Échelle finale", "ui.entities": "Entités", "ui.equal": "Égalité", + "ui.equation_end": "Fin", + "ui.equation_explicit": "Explicite", + "ui.equation_form": "Forme", + "ui.equation_note": "La variable est un simple nombre : sin(t) la prend en radians. Un résultat sans unité est dans l'unité de la conception.", + "ui.equation_parametric": "Paramétrique", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Rayon équivalent", "ui.expand": "Développer", "ui.exportDxf": "Exporter en DXF…", @@ -3789,6 +4209,7 @@ "ui.heading_design": "DESIGN", "ui.heading_draft": "DÉPOUILLE", "ui.heading_edit": "ÉDITION", + "ui.heading_equation_curve": "COURBE PAR ÉQUATION", "ui.heading_extrude": "EXTRUSION", "ui.heading_fillet": "CONGÉ", "ui.heading_finish_editing": "TERMINER LA MODIFICATION", @@ -3880,6 +4301,10 @@ "ui.import_ellipsis": "Importer…", "ui.infinite": "Infinie", "ui.insert": "Insérer", + "ui.insert_design.browse": "Parcourir…", + "ui.insert_design.open_designs": "Conceptions ouvertes", + "ui.insert_design.saved_file": "{name} contient des modifications non enregistrées ; c'est donc son fichier enregistré qui a été inséré. Enregistrez-le, puis utilisez Mettre à jour les liens pour intégrer les modifications.", + "ui.insert_design.unsaved": "Modifications non enregistrées", "ui.inspect": "Inspecter", "ui.intersect": "Intersection", "ui.isolate": "Isoler", @@ -4009,12 +4434,17 @@ "ui.plugins_skipped": "{count} dossier(s) illisible(s).", "ui.plugins_version": "{version}", "ui.point": "Point", + "ui.point_cloud_add_filter": "Ajouter un filtre…", "ui.point_cloud_auto_range": "Plage automatique", "ui.point_cloud_color_by": "Colorer par", "ui.point_cloud_file_colors": "Couleurs du fichier", + "ui.point_cloud_filter_by": "Filtrer par", "ui.point_cloud_height_high": "Plus haut", "ui.point_cloud_height_low": "Plus bas", + "ui.point_cloud_legend_filter": "Affichées et masquées dans la légende ci-dessus", "ui.point_cloud_no_file_colors": "Le fichier n'a pas de couleurs", + "ui.point_cloud_pause": "Pause", + "ui.point_cloud_play": "Lire", "ui.point_cloud_single_color": "Couleur unique", "ui.point_cloud_size": "Taille des points", "ui.point_cloud_units": "Unités du fichier", @@ -4023,12 +4453,29 @@ "ui.polygon": "Polygone", "ui.preferences": "Préférences", "ui.press_a_key": "appuyez sur une touche…", + "ui.print_face_cleared": "{body} : face d’impression effacée", + "ui.print_face_input": "Face d’impression", + "ui.print_face_one_body": "Une face d’impression appartient à un seul corps : envoyez ce corps seul pour la modifier", + "ui.print_face_pick": "Sélectionnez une face plane d’un corps solide pour en faire la face d’impression", + "ui.print_face_set": "{body} : face d’impression définie ; Envoyer au slicer pose le corps sur cette face", "ui.profile": "Profil", "ui.profile_plane": "Plan du profil", "ui.profiles": "Profils", "ui.project": "Projeter", "ui.project_face_edges_sketch": "Projeter les arêtes de la face dans les nouvelles esquisses", "ui.properties": "Propriétés", + "ui.properties.copy_matrix": "Copier la matrice", + "ui.properties.first_moments": "Moments statiques", + "ui.properties.inertia": "Moment d'inertie", + "ui.properties.inertia_note": "Par rapport au centre de gravité, dans les axes du monde (g·{unit}²)", + "ui.properties.inertia_unavailable": "Non disponible : le maillage d'un corps n'est pas fermé.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mesure en cours…", + "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", "ui.pull_direction": "Direction de démoulage", "ui.quality": "Qualité", "ui.quantity": "Quantité", @@ -4074,6 +4521,7 @@ "ui.revolve": "Révolution", "ui.ribbon_exit": "Quitter", "ui.ribbon_labels": "Afficher le texte sous les icônes du ruban", + "ui.ribbon_menus_skip_pinned": "Laisser les commandes épinglées hors des menus déroulants", "ui.ribbon_pin_footer": "Les commandes épinglées s’affichent dans le ruban. Survolez une ligne pour l’épingler ou la détacher.", "ui.ribbon_workspace": "Espace de travail", "ui.right": "Droite", @@ -4128,7 +4576,11 @@ "ui.sketch": "Esquisse", "ui.sketch_dimension": "Cote d'esquisse", "ui.sketch_grid": "Grille d'esquisse", + "ui.sketch_intersect_geometry": "Géométrie", + "ui.sketch_intersect_projection_link": "Lien de projection", + "ui.sketch_intersect_selection_filter": "Filtre de sélection", "ui.sketch_modifiers": "Modificateurs d’esquisse", + "ui.sketch_project_geometry": "Géométrie", "ui.sketch_scale": "Échelle d'esquisse", "ui.sketches": "Esquisses", "ui.slice": "Trancher", @@ -4179,6 +4631,7 @@ "ui.tab_manage": "Gérer", "ui.tab_sheet_metal": "Tôlerie", "ui.tab_view": "Vue", + "ui.tab_weldments": "Mécano-soudure", "ui.tangent": "Tangence", "ui.taper_angle": "Angle de dépouille", "ui.tapped": "Taraudé", @@ -4194,6 +4647,7 @@ "ui.tip_angle": "Angle de pointe", "ui.to_object": "Jusqu'à l'objet", "ui.tool_direction": "Direction de l'outil", + "ui.toolbar_tabs_hidden": "Tous les onglets de la barre d'outils sont masqués. Cliquez sur + ou faites un clic droit ici pour en afficher un.", "ui.top": "Dessus", "ui.top_diameter": "Diamètre supérieur", "ui.top_radius": "Rayon supérieur", @@ -4231,6 +4685,8 @@ "ui.view_centre": "Centre de la vue", "ui.viewport_status_clear": "Désactiver {name}", "ui.viewport_status_clear_hint": "Désactiver {name} · Échap", + "ui.viewport_status_edges": "{count} arêtes", + "ui.viewport_status_from": "depuis {direction}", "ui.viewport_status_key": "Afficher la légende de {name}", "ui.viewport_status_minimum_radius": "Rayon minimum", "ui.viewport_status_pairs": "{count} paires", @@ -4240,6 +4696,7 @@ "ui.visual_shaded_with_edges": "Ombré avec arêtes", "ui.visual_style": "Style visuel", "ui.visual_wireframe": "Filaire", + "ui.wheel_zoom_speed": "Vitesse du zoom à la molette", "ui.width": "Largeur", "ui.window_system": "Système de fenêtrage", "ui.window_system_auto": "Automatique", @@ -4270,7 +4727,9 @@ "update.button": "Mettre à jour", "update.button_version": "Mettre à jour vers {version}", "update.check_automatically": "Rechercher automatiquement les mises à jour", + "update.check_for_updates": "Rechercher des mises à jour", "update.check_now": "Vérifier maintenant", + "update.check_unavailable": "La recherche de mises à jour n’est pas disponible pour cette installation.", "update.checking": "Vérification…", "update.copy": "Copier", "update.dmg_opened": "L'image disque est ouverte et vérifiée : faites glisser SolidCraft dans Applications en remplaçant l'ancienne copie, puis redémarrez SolidCraft.", @@ -4284,13 +4743,18 @@ "update.installed": "SolidCraft {version} est installé. Redémarrez pour l'utiliser.", "update.installer_started": "L'installateur démarre à la fermeture de SolidCraft.", "update.later": "Me le rappeler plus tard", + "update.managed": "Mise à jour disponible : mettez à jour SolidCraft dans {manager}", "update.no_notes": "Cette version n'a pas de notes.", "update.package_hint": "Cette copie provient d'un {kind}. Téléchargez le nouveau paquet, puis installez-le avec :", "update.prefs_dev": "Ceci est une version de développement ({version}) : elle ne vérifie pas les mises à jour.", "update.prefs_flatpak": "Flatpak met à jour cette copie ({version}) : exécutez {command} ou utilisez votre logithèque. SolidCraft ne vérifie pas.", "update.prefs_label": "Mises à jour", + "update.prefs_managed": "Ceci est SolidCraft {version}, géré par {manager}. Une vérification interroge GitHub sur la dernière version et n'envoie rien d'autre ; mettez à jour SolidCraft dans {manager}.", "update.prefs_note": "Ceci est SolidCraft {version} ({kind}). Une vérification interroge GitHub sur la dernière version et n'envoie rien d'autre.", "update.prefs_web": "Rechargez la page pour obtenir la dernière version de l'application web. Si le réseau est indisponible ou si la mise à jour ne peut pas finir de se télécharger à temps, la version enregistrée s'ouvre à la place ; un téléchargement en cours se poursuit pour le prochain rechargement.", + "update.refresh_checking": "Recherche de la dernière version…", + "update.refresh_fallback": "Impossible de rechercher une version plus récente ; mise à jour vers {version}, trouvée plus tôt.", + "update.refresh_selected": "Mise à jour vers {version}.", "update.release_page": "Ouvrir la page de la version", "update.restart": "Redémarrer maintenant", "update.restart_later": "Plus tard", diff --git a/assets/locales/gl.json b/assets/locales/gl.json index 6902de5c7f..80b3bb4f49 100644 --- a/assets/locales/gl.json +++ b/assets/locales/gl.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Os deseños que abras ou gardes aparecen aquí.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Ata o obxecto. Escolle Ángulo para editar a extensión angular.", "Diameter": "Diámetro", + "Did this assembly open the wrong way up?": "Abriuse esta ensamblaxe mal orientada?", + "Did this part open the wrong way up?": "Abriuse esta peza mal orientada?", "Dimension new shapes": "Cotar as formas novas", "Direction": "Dirección", "Discard": "Descartar", @@ -204,6 +206,7 @@ "Edges": "Arestas", "Edit": "Editar", "Edit Profile Sketch": "Editar o bosquexo do perfil", + "Edit shortcuts": "Editar os atallos", "Enable rule": "Activar a regra", "Enabled": "Activado", "End Radius": "Raio final", @@ -466,6 +469,7 @@ "Radius": "Raio", "Radius Fit": "Axuste do raio", "Ratio": "Proporción", + "Read-only list: shortcuts can't be changed here.": "Lista de só lectura: aquí non se poden cambiar os atallos.", "Reattach selected face": "Volver ancorar a cara seleccionada", "Recover": "Recuperar", "Recover unsaved design?": "Recuperar o deseño sen gardar?", @@ -599,6 +603,7 @@ "Square": "Cadrado", "Stand it up": "Poñela de pé", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poñela de pé: un cuarto de volta arredor de X, como unha operación Mover que podes desfacer.", + "Stand this assembly up?": "Poñer esta ensamblaxe de pé?", "Stand this part up?": "Poñer esta peza de pé?", "Start angle": "Ángulo inicial", "Start bulge": "Abaulamento inicial", @@ -649,6 +654,7 @@ "Third point": "Terceiro punto", "This child is shared. Deleting it removes it from every copy of its parent.": "Este fillo está compartido. Eliminalo quítao de todas as copias do seu pai.", "This child is shared. Position changes apply to every copy of its parent.": "Este fillo está compartido. Os cambios de posición aplícanse a todas as copias do seu pai.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Este compoñente está dentro dunha subensamblaxe colocada máis dunha vez: aliñalo móveo en todas as colocacións.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara non pode ofrecer unha ancoraxe duradeira. Escolle outra cara ou usa expresamente un plano fixo.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Esta imaxe aínda non está anexada a GitHub. Copia ou garda a imaxe e despois anéxaa en GitHub.", "Thread depth": "Profundidade da rosca", @@ -769,6 +775,8 @@ "at the face": "na cara", "bom.group_children": "Subpezas na fila da súa peza", "browser.body_name": "Corpo{number}", + "browser.calibrate_click_a": "Fai clic na imaxe para o punto A, ou escribe as súas coordenadas.", + "browser.calibrate_click_b": "Fai clic na imaxe para o punto B, ou escribe as súas coordenadas.", "browser.calibrate_title": "Calibrar: {name}", "browser.capture_many": "Movéronse {count} compoñentes, pero as súas posicións non se capturaron.", "browser.capture_note": "Capturar a posición conserva a nova posición; Reverter a posición restaura a anterior.", @@ -790,6 +798,11 @@ "cam.roller": "Rolete", "cam.roller_pick": "Rolete (opcional)", "cam.tip_pick": "Punta (opcional)", + "camera.shot_pitch": "Cabeceo", + "camera.shot_roll": "Balanceo", + "camera.shot_yaw": "Guiñada", + "canvas.decal_place_hint": "Fai clic nunha cara para colocar a calcomanía", + "canvas.place_hint": "Fai clic nun plano ou nunha cara para colocar o lenzo", "choose a profile": "escolle un perfil", "choose the cam's and the follower's joints": "escolle as unións da leva e do seguidor", "choose the joints to couple": "escolle as unións que acoplar", @@ -797,12 +810,16 @@ "click sketch curves": "fai clic en curvas do bosquexo", "cmd.appearance_assign": "Aparencia", "cmd.appearance_chain": "Cadea de aparencias", + "cmd.appearance_copy": "Copiar o estilo", + "cmd.appearance_favorite": "Estilo favorito", "cmd.appearance_library": "Biblioteca de aparencias", "cmd.appearance_list": "Listar as aparencias", "cmd.appearance_paint": "Pintar aparencia", + "cmd.appearance_paste": "Pegar o estilo", "cmd.appearance_pick_up": "Tomar aparencia", "cmd.appearance_replace": "Substituír aparencia", "cmd.assembly_bom": "Lista de materiais", + "cmd.body.print_face": "Cara de impresión", "cmd.body_rename": "Renomear o corpo", "cmd.browser_collapse_all": "Contraer todo", "cmd.browser_expand_all": "Expandir todo", @@ -810,6 +827,7 @@ "cmd.browser_move": "Mover elementos do navegador", "cmd.browser_order": "Ordenar elementos do navegador", "cmd.browser_rename_group": "Renomear o grupo", + "cmd.browser_reparent": "Mover ao compoñente", "cmd.browser_ungroup": "Desagrupar", "cmd.browser_visibility": "Mostrar/Ocultar", "cmd.canvas_calibrate": "Calibrar o lenzo", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Propiedades da peza", "cmd.component_metadata_set": "Cambiar as propiedades da peza", "cmd.component_move_bodies": "Mover corpos ao compoñente", + "cmd.component_move_features": "Mover operacións ao compoñente", "cmd.component_move_sketches": "Mover bosquexos ao compoñente", + "cmd.component_paste_from_design": "Pegar desde outro deseño", "cmd.component_paste_new": "Pegar como novo", "cmd.component_properties": "Propiedades físicas do compoñente", "cmd.component_relink": "Volver ligar a orixe", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Traxectoria do movemento", "cmd.motion_trace_edit": "Editar a traxectoria do movemento", "cmd.motion_trace_keep": "Manter a traxectoria do movemento", + "cmd.occurrence_align": "Aliñar a aparición", "cmd.occurrence_copy": "Pegar (instancia)", "cmd.occurrence_drag": "Arrastrar a aparición", "cmd.occurrence_ground": "Fixar ao chan", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Quitar a vinculación da superficie", "cmd.render_library_targets": "Buscar destinos da biblioteca de render", "cmd.render_lights_add": "Engadir luz", + "cmd.render_lights_duplicate": "Duplicar a luz do render", + "cmd.render_lights_list": "Listar as luces do render", + "cmd.render_lights_mute": "Silenciar a luz", + "cmd.render_lights_remove": "Quitar a luz do render", + "cmd.render_lights_set": "Definir a luz do render", "cmd.render_lights_solo": "Luz en solo", "cmd.render_optics_bind": "Gardar a vinculación óptica", "cmd.render_optics_unbind": "Quitar a vinculación óptica", "cmd.render_output_size": "Definir o tamaño de saída do render", "cmd.render_preflight": "Comprobar a solicitude de render", + "cmd.render_preflight_copy": "Copiar o informe da comprobación do render", "cmd.render_preset_apply": "Aplicar o Studio nativo", "cmd.render_preset_resolve": "Revisar o Studio nativo", "cmd.render_quality_prepare": "Revisar o orzamento fixo de mostraxe", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Renderizar imaxe", "cmd.render_status": "Progreso do render", "cmd.select_clear": "Borrar a selección", + "cmd.settings_profile_apply": "Aplicar o perfil de axustes", + "cmd.settings_profile_delete": "Eliminar o perfil de axustes", + "cmd.settings_profile_export": "Exportar o perfil de axustes", + "cmd.settings_profile_import": "Importar un perfil de axustes", + "cmd.settings_profile_list": "Perfís de axustes", + "cmd.settings_profile_rename": "Renomear o perfil de axustes", + "cmd.settings_profile_save": "Gardar o perfil de axustes", "cmd.sheet_convert": "Converter en chapa", "cmd.sheet_export_bend_table": "Exportar a táboa de dobras", "cmd.sheet_export_dxf": "Exportar o desenvolvemento como DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Volver dobrar", "cmd.sheet_rules": "Listar as regras de chapa", "cmd.sheet_unfold": "Despregar", + "cmd.sketch.construction_line": "Recta de construción", + "cmd.sketch.construction_line.angle": "Recta de construción con ángulo", + "cmd.sketch.construction_line.bisector": "Bisectriz", + "cmd.sketch.construction_line.horizontal": "Recta de construción horizontal", + "cmd.sketch.construction_line.parallel": "Recta de construción paralela", + "cmd.sketch.construction_line.perpendicular": "Recta de construción perpendicular", + "cmd.sketch.construction_line.tangent_at": "Tanxente nun punto da curva", + "cmd.sketch.construction_line.tangent_external": "Tanxente por un punto exterior", + "cmd.sketch.construction_line.tangent_two": "Tanxente común", + "cmd.sketch.construction_line.vertical": "Recta de construción vertical", "cmd.sketch_arc3d": "Arco 3D", "cmd.sketch_arc_center_point": "Arco por punto central", "cmd.sketch_arc_tangent": "Arco tanxente", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Tamaño do debuxo", "cmd.sketch_edit": "Editar o bosquexo", "cmd.sketch_edit_text": "Editar o texto", + "cmd.sketch_equation_curve": "Curva de ecuación", + "cmd.sketch_equation_curve_edit": "Editar a curva de ecuación", "cmd.sketch_export_dxf": "Gardar como DXF", "cmd.sketch_finish_auto_constrain": "Rematar con restrición automática", "cmd.sketch_fit_curves_to_section": "Axustar curvas a unha sección de malla", @@ -1198,6 +1244,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_items": "Elementos de estado da ventá gráfica", "cmd.view_ambient_occlusion": "Oclusión ambiental", "cmd.view_angle_readout": "Ángulos da vista", "cmd.view_apply": "Restaurar a vista con nome", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Normal á selección", "cmd.view_previous_tab": "Lapela anterior", "cmd.view_rename": "Renomear a vista con nome", + "cmd.view_render_frame": "Guías de encadre do render", "cmd.view_right": "Vista dereita", "cmd.view_save": "Vista con nome nova", "cmd.view_shots_strip": "Franxa de tomas", @@ -1229,10 +1277,14 @@ "color.copy": "Preme para copiar os valores da cor", "color.edit": "Preme para editar a cor", "color.gamma": "Mostrando os valores da cor no espazo gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Escribe #rrggbb ou #rrggbbaa; con seis díxitos consérvase a opacidade", + "color.hex_hint": "#rrggbb", "color.hue": "Ton", "color.linear": "Mostrando os valores da cor no espazo lineal 0-1", "color.saturation_value": "Saturación e valor", "color.selected": "Cor seleccionada", + "config.column_filter_empty": "Non hai columnas que coincidan", "contact.components_count": "{count} compoñentes", "contact.enabled_label": "activado", "coupling.gear_1": "Engrenaxe 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Faga dobre clic noutra cota de control para inserir o seu nome", "dialog.dimension_name": "Cota: {name}", "dialog.edit_title": "EDITAR {title}", + "dialog.no_shown_body": "Non hai ningún corpo visible para modificar: mostra un ou escolle Corpo novo", "dialog.select": "↖ Seleccionar", "dialog.selected_count": "↖ {count} seleccionados", "dialog.selection_guidance": "Selecciona unha entrada para cambiala. Fai clic nos obxectos seleccionados para quitalos; Ctrl/Cmd-clic tamén os alterna. Revisa a previsualización e preme Aceptar.", @@ -1346,8 +1399,20 @@ "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.mit": "Licenza MIT", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Crea unha recta de construción horizontal infinita por un punto. A súa restrición horizontal mantense nas edicións posteriores.", + "help.sketch.construction_line.parallel": "Escolle unha recta do bosquexo e despois coloca unha recta de construción infinita paralela. Fai clic co botón dereito para escoller outra referencia.", + "help.sketch.construction_line.perpendicular": "Escolle unha recta do bosquexo e despois coloca unha recta de construción infinita perpendicular. Fai clic co botón dereito para escoller outra referencia.", + "help.sketch.construction_line.tangent_at": "Crea unha tanxente nun punto dun círculo ou dun arco circular, ou no extremo dunha spline ou dunha cónica abertas. Escolle a curva e despois o seu punto de tanxencia.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Eliminar", "hole_presets.delete_prompt": "Eliminar este axuste predefinido persoal? Os furados existentes non cambian.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Estas pezas son corpos dun mesmo compoñente: {bodies}. Unha unión move un compoñente respecto doutro, así que cada peza precisa o seu propio compoñente.", "joint.rigid_group_one_component": "Estes {count} corpos están todos nun só compoñente (ou no propio deseño): usa primeiro {command}.", "joint.rigid_group_select_components": "Selecciona dous ou máis compoñentes.", + "joint_move.blocked_hint": "Comproba o tipo de unión (como Ríxida), a fixación ao chan, os límites da unión e os valores controlados por parámetros.", + "joint_move.blocked_title": "Non se pode mover esta peza", + "joint_move.coarse": "Paso de rotación preto do eixe (°)", + "joint_move.distance": "Distancia desde o inicio", + "joint_move.done": "Feito", + "joint_move.fine": "Paso de rotación lonxe do eixe (°)", + "joint_move.fixed": "Esta peza non ten ningún eixe de unión móbil.", + "joint_move.help": "Mover — arrastra unha frecha ou un anel. Esc cancela o arrastre actual.", + "joint_move.legacy_hint": "Desactívao para usar o panel Mover anterior.", + "joint_move.precision": "Tira cara a fóra da guía descontinua para unha rotación fina.", + "joint_move.rotation": "Rotación desde o inicio", + "joint_move.select": "Selecciona unha cara ou unha peza da ensamblaxe e despois escolle Mover.", + "joint_move.settings": "Mover na ensamblaxe", + "joint_move.step": "Paso", + "joint_move.use_handles": "Usar asas de movemento das unións para Mover na ensamblaxe", "keymap.command_id": "ID do comando", "keymap.empty": "Non hai comandos coincidentes", "keymap.use_anyway": "Usar {key} aquí de todos os xeitos", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+arrastrar co botón esquerdo orbita, Alt+botón central desprázase, Alt+botón dereito fai zoom; arrastrar co botón central tamén desprázase", "nav.openscad.help": "Arrastrar co botón esquerdo sobre un espazo baleiro orbita, Shift+arrastrar co botón esquerdo selecciona por caixa, arrastrar a xeometría do bosquexo edítaa; arrastrar co botón dereito ou central desprázase, Shift+arrastrar co botón dereito ou central fai zoom", "nav.perspective.sketch": "A edición de bosquexos usa a vista ortogonal. Remata o bosquexo para cambiar á perspectiva.", + "nav.right_drag_orbits.help": "Desactivado: arrastrar co botón dereito non move a cámara e o botón dereito só abre o menú contextual. Escoller a asignación do rato de SolidWorks desactívao.", + "nav.right_drag_orbits.label": "Arrastrar co botón dereito orbita", "nav.solidworks.help": "Arrastrar co botón central orbita, Ctrl+botón central desprázase, Shift+botón central fai zoom, arrastrar co botón dereito orbita", "nav.tinkercad.help": "Arrastrar co botón dereito orbita, arrastrar co botón central ou Shift+botón dereito desprázase", "nav.touchpad.help": "Mantén Maiús e move o punteiro para desprazar; mantén Alt/Opción e móveo para orbitar. Non fai falta ningún botón do rato. O desprazamento vertical fai zoom.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Personalizar a barra de navegación…", "navbar.custom.design": "Deseño", "navbar.custom.duplicate": "Este elemento xa está na barra.", + "navbar.custom.edge": "Bordo", + "navbar.custom.edge_bottom": "Inferior", + "navbar.custom.edge_top": "Superior", "navbar.custom.fade": "Esvaecer", "navbar.custom.full": "Quita un elemento para engadir outro.", "navbar.custom.hidden_design": "Oculto en Deseño", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Espesor", "parts.dimension.width": "Largura", "parts.family.bearing": "Rodamento rígido de bólas (envolvente)", + "parts.family.button_head_cap_screw": "Parafuso de cabeza abombada con hexágono interior", "parts.family.dowel_pin": "Pasador paralelo (espiga)", "parts.family.hex_bolt": "Parafuso / perno de cabeza hexagonal", "parts.family.hex_nut": "Porca hexagonal", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Suaviza as arestas dentadas.", "prefs.applies_on_restart": "Aplícase ao reiniciar", "prefs.assistant": "Asistente", + "prefs.auto_operation": "Escoller a operación automaticamente", + "prefs.auto_operation_note": "Os diálogos das operacións escollen Unir, Corpo novo ou Cortar segundo o que toque o material novo, ata que escollas unha. Desactivado: manteñen a operación coa que se abren.", "prefs.axes": "Eixes", "prefs.axis": "Eixe", "prefs.bodies_folders": "Cartafoles de corpos", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Inferior centro", "prefs.bottom_right": "Inferior dereita", "prefs.browser": "Navegador", + "prefs.browser_eye_left": "Ollo xunto á frecha de expansión", + "prefs.browser_eye_left_note": "As filas do navegador mostran o seu ollo de mostrar/ocultar xunto á frecha de expansión, como antes, en vez de nunha columna no extremo dereito das filas.", + "prefs.browser_reveal_on_hover": "Mostrar ao pasar o punteiro", + "prefs.browser_show_note": "Visible mantén o navegador na pantalla. Con Mostrar ao pasar o punteiro, queda oculto ata que o punteiro pasa polo seu lugar ou pola lapela estreita do seu bordo esquerdo, e segue aberto mentres o punteiro está dentro del ou hai aberto un menú del.", + "prefs.browser_shown": "Visible", "prefs.built_in": "Integrado", "prefs.button_left": "Arrastrar co botón esquerdo", "prefs.button_middle": "Arrastrar co botón central", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Aplícase despois de reiniciar SolidCraft. WGPU_ADAPTER_NAME anula esta opción.", "prefs.graphics_note": "Troca velocidade por suavidade. Cada opción mostra o seu efecto nunha mostra.", "prefs.graphics_presets_note": "Os axustes predefinidos configuran xuntas as opcións de abaixo.", + "prefs.hide_constraint_glyphs_zoomed_out": "Ocultar as iconas de restricións ao reducir o zoom", + "prefs.hide_constraint_glyphs_zoomed_out_note": "As iconas de restricións desaparecen da vista cando o zoom do bosquexo está tan afastado que o sobrecargarían. Desactivado: manteñen un tamaño fixo na pantalla con calquera zoom.", "prefs.history": "Historial", "prefs.history_origin_bodies": "Mostrar a orixe e os corpos na parte superior", "prefs.history_origin_bodies_note": "O Historial da esquerda ou baixo o navegador comeza co cartafol Corpos do compoñente activo e coa Orixe, cos seus ollos e menús coma no navegador. O Historial da parte inferior non cambia.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Compacta acopla os paneis aos bordos da xanela. A translucidez do navegador está dispoñible en Flotante. Cambia o lado do Asistente na páxina do Asistente.", "prefs.left": "Esquerda", "prefs.left_click": "Clic esquerdo", + "prefs.line_drag_draws_one": "Premer e arrastrar debuxa unha liña", + "prefs.line_drag_draws_one_note": "Na ferramenta Liña, premer, arrastrar e soltar debuxa unha liña desde onde se preme ata onde se solta, e remata alí. Os clics seguen debuxando unha cadea. Desactivado: un arrastre coloca un punto onde se solta, como fai un clic.", "prefs.linked_designs": "Deseños ligados", "prefs.live_input": "Entrada en directo", "prefs.live_preview": "Vista previa en directo", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "Ficheiros STEP e de importación", "prefs.max_file_size_web": "A aplicación web abre ficheiros STEP e de importación de ata 512 MB e deseños de ata 256 MB: unha lapela do navegador non admite ficheiros maiores. A aplicación de escritorio permite aumentar estes límites.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Iconas grandes", + "prefs.menu_list": "Lista", + "prefs.menus": "Menús", "prefs.minutes": "minutos", "prefs.minutes_suffix": " min", "prefs.mouse": "Rato e panel táctil", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Colocación", "prefs.prefer_free_sketch_points": "Preferir os puntos de bosquexo móbiles cando os puntos se solapan", "prefs.preview_choices": "Vista previa das túas opcións", + "prefs.print_face_highlight": "Resaltado da cara de impresión", + "prefs.print_face_highlight_tip": "Intensidade da tinguidura e do contorno da cara de impresión dun corpo na vista. 0 % non mostra resaltado.", + "prefs.profile_active": "Perfil activo", + "prefs.profile_applied": "Perfil aplicado.", + "prefs.profile_changed": "Os axustes cambiaron desde que se gardou o perfil. Gardar consérvaos nel.", + "prefs.profile_deleted": "Perfil eliminado.", + "prefs.profile_export": "Exportar…", + "prefs.profile_exported": "Perfil exportado.", + "prefs.profile_imported": "Perfil importado. Escólleo en Perfil activo para cambiar a el.", + "prefs.profile_name_hint": "Nome do perfil, p. ex. Portátil", + "prefs.profile_rename": "Renomear", + "prefs.profile_renamed": "Perfil renomeado.", + "prefs.profile_save": "Gardar", + "prefs.profile_saved": "Gardáronse os axustes actuais no perfil.", + "prefs.profile_skipped": "Perfil aplicado; {n} axuste(s) conservaron o seu valor actual (consulta o rexistro).", + "prefs.profiles": "Perfís de axustes", + "prefs.profiles_note": "Os atallos, a barra de ferramentas, a navegación, o menú radial e os axustes da interface, gardados xuntos baixo un nome e cambiados dunha vez.", + "prefs.project_options_dialog": "Diálogo de opcións de Proxectar", + "prefs.project_options_dialog_note": "Activado: escolle Xeometría ou Corpos e Ligazón de proxección antes de proxectar. Desactivado: mantén o fluxo de traballo anterior de selección directa.", "prefs.quality_default": "predeterminado", "prefs.quality_fastest": "máis rápido", "prefs.quality_slowest": "máis lento", "prefs.quality_smoother": "máis suave", + "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", + "prefs.ribbon_reveal": "Mostrar os resultados da busca na cinta", + "prefs.ribbon_reveal_note": "Ao pasar o punteiro sobre un resultado en Buscar ordes, móstrase onde está a orde na cinta: a súa lapela, o seu botón ou a súa fila no despregable dun panel. Unha orde iniciada desde a busca escintila alí, e unha orde en execución desde un despregable marca o seu panel.", "prefs.right_click": "Clic dereito", "prefs.saving": "Gardado automático e recuperación", "prefs.scale_note": "Tamén Ctrl + / Ctrl − na xanela. Os botóns miden 28 px ao 100%.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Non hai axustes que coincidan", "prefs.select": "Seleccionar", "prefs.select_one_item": "Seleccionar un elemento", + "prefs.select_other_hidden": "Seleccionar a xeometría oculta con Seleccionar outro", + "prefs.select_other_hidden_help": "Activado: inclúe os corpos e os bosquexos ocultos e as arestas ocluídas preto do punteiro. Desactivado: usa a lista orixinal de xeometría visible baixo o punteiro.", "prefs.selecting": "Selección", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift só engade; Ctrl / Cmd alterna", "prefs.shift_add_note": "Activado: Shift engade, Ctrl / Cmd alterna. Desactivado (predeterminado): ambos alternan.", "prefs.shortcuts": "Atallos", + "prefs.single_surface_analysis": "Usar unha única análise de superficie", + "prefs.single_surface_analysis_help": "Cando está desactivado, o sombreado de superficie e Continuidade poden estar activos á vez. Ao activalo, mantense a última análise iniciada ou cambiada.", + "prefs.sketch_align_points": "Aliñar con outros puntos do bosquexo ao debuxar", + "prefs.sketch_align_points_note": "Ao debuxar, un punto alíñase en horizontal cun punto do bosquexo e en vertical con outro á vez, cunha guía punteada, e o clic conserva os dous aliñamentos. Desactivado: só co punto anterior, un eixe de cada vez.", "prefs.sketch_conflict_colour": "Xeometría en conflito na cor de conflito", "prefs.sketch_conflict_colour_note": "Mentres as restricións dun bosquexo están en conflito, as curvas e os puntos que suxeita unha restrición que falla tamén se debuxan na cor de conflito, non só o seu símbolo ou a súa cota. Desactivado: manteñen as súas cores habituais.", + "prefs.sketch_construction_lines": "Ferramentas ampliadas de rectas de construción", "prefs.sketch_exit_view": "Ao rematar un bosquexo", "prefs.sketch_exit_view.keep": "Manter a vista actual", "prefs.sketch_exit_view.previous": "Volver á vista anterior", "prefs.sketch_exit_view.unless_rotated": "Volver, salvo que se xirase", "prefs.sketch_exit_view_note": "A onde vai a vista despois de Finalizar o bosquexo, Cancelar ou un dobre Esc.", + "prefs.sketch_open_look_at": "Xirar para mirar cara ao bosquexo cando se abre", + "prefs.sketch_open_look_at_note": "Desactivado: crear ou editar un bosquexo deixa a vista tal e como está. Mirar cara a orienta a vista cara ao bosquexo cando o queres.", + "prefs.sketch_recent_snap": "Axustar automaticamente aos centros e puntos medios recentes", + "prefs.sketch_recent_snap_note": "Ao debuxar, o cursor axústase onde se cruzan as guías dos dous últimos centros de círculo ou puntos medios de liña recta. Desactivado: esas guías só se mostran e o cursor non se move.", "prefs.sketch_selection": "Bosquexo e selección", "prefs.sketch_selection_heading": "Bosquexo e selección", "prefs.sketch_selection_note": "Como se axustan os bosquexos e como seleccionan os clics.", + "prefs.sketch_state_icons": "Iconas dos bosquexos na cor do seu estado", + "prefs.sketch_state_icons_note": "No Historial e no navegador, a icona dun bosquexo toma a cor do seu estado (subrestrinxido, totalmente restrinxido ou en conflito) e a súa información emerxente nomea o estado. Desactivado: todos os bosquexos teñen a mesma icona.", "prefs.sketch_tab_always": "Manter visible a lapela Bosquexo", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Fonte", "prefs.speed": "Velocidade", "prefs.stand_up_note": "Despois de abrir un ficheiro STEP ou IGES con Z cara arriba, ofrece un cuarto de volta arredor de X.", + "prefs.stand_up_unknown": "Para ficheiros de programas descoñecidos", + "prefs.stand_up_unknown.ask": "Preguntar nun diálogo", + "prefs.stand_up_unknown.note": "Mostrar unha nota", + "prefs.stand_up_unknown.off": "Non ofrecer", + "prefs.stand_up_unknown_note": "Como se fai a oferta cando a cabeceira do ficheiro non nomea ningún programa que se saiba que garda con Y cara arriba. Só se mostra unha nota cando o ficheiro parece estar deitado; Preguntar nun diálogo pregunta por cada ficheiro así, como antes.", "prefs.status_bar": "Barra de estado", "prefs.status_bar_note": "Unha barra fina ao longo do bordo inferior da xanela mostra o que se está a editar, o estado do bosquexo aberto na súa cor e as unidades do deseño; un clic nas unidades cámbiaas. O estado do bosquexo xa non aparece entón na esquina da vista.", "prefs.status_done": "Feito", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Avisos", "prefs.toggle_item": "Engade o elemento ou quítao de novo", "prefs.toolbar": "Barra de ferramentas", + "prefs.toolbar_tabs": "Lapelas da barra de ferramentas", "prefs.top": "Superior", + "prefs.trackpad_zoom_modifier": "Zoom co desprazamento con dous dedos mantendo premida a tecla", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Mantén premida só esta tecla para facer zoom no punteiro co desprazamento vertical. Desactivado mantén o comportamento actual do desprazamento.", + "prefs.trackpad_zoom_modifier.off": "Desactivado", "prefs.translucent": "Translúcido", "prefs.under_browser": "Baixo o navegador", "prefs.under_cursor": "Baixo o cursor", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Ventá gráfica", + "prefs.viewport_analysis_status": "Mostrar o estado de análise da ventá gráfica", + "prefs.viewport_analysis_status_help": "Mostra as análises activas sobre a ventá gráfica, abre as súas lendas desde cada elemento e usa Esc para desactivar a análise máis recente. Desactiva para restaurar as lendas nas esquinas e o comportamento anterior de Esc.", + "prefs.viewport_background": "Fondo", + "prefs.viewport_background_colour": "Cor", + "prefs.viewport_background_theme": "Degradado do tema", "prefs.wheel": "Roda", "prefs.wheel_trackpad": "Roda e panel táctil", "prefs.workspace": "Espazo de traballo", + "prefs.zoom_stop_at_model": "Deter o zoom no modelo", + "prefs.zoom_stop_at_model_tip": "O rato 3D e a aplicación do dispositivo fan zoom cara á superficie do medio da vista; o zoom por arrastre mantén o punto onde comezou o arrastre. Desactivado: o zoom pode atravesar o modelo. A roda e a pinza usan Zoom cara ao cursor por separado", "prefs.zoom_toward_cursor": "Zoom cara ao cursor", "properties.title": "Propiedades: {name}", "recovery.days_ago": "hai {count} días", "recovery.detail": "{file} · {features} operacións · gardado automaticamente {when}", + "recovery.discard_all": "Descartar todo", + "recovery.discard_all_detail": "Esta acción non se pode desfacer.", + "recovery.discard_all_title": "Descartar {count} gardados automáticos?", + "recovery.discard_all_title_one": "Descartar 1 gardado automático?", "recovery.hours_ago": "hai {count} h", "recovery.just_now": "agora mesmo", "recovery.min_ago": "hai {count} min", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Peso", "render.appearance_physics_separate": "A aparencia visual non asigna densidade, datos térmicos nin outras propiedades de enxeñaría.", "render.appearances": "Aparencias", + "render.assign_help": "Fai clic para aplicar á selección; arrastra para previsualizar e solta, ou fai clic sen nada seleccionado para pintar.", "render.availability_native": "Composición nativa; revísaa antes de aplicar", "render.availability_projection": "Proxección admitida; consulta o alcance e as omisións", "render.availability_reference": "Datos de referencia; non é unha asignación automática", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Limiar de realce lineal", "render.browse_capabilities": "Inspeccionar o catálogo completo de capacidades", "render.browse_environments": "Explorar contornos HDR orixinais", + "render.browse_looks": "Explorar…", "render.browse_native_scenes": "Explorar estudios nativos", "render.browse_physical": "Inspeccionar referencias físicas", "render.browse_quality": "Inspeccionar especificacións de calidade", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Transparencia: só superficies opacas; admítese o alfa do fondo.", "render.check_sorted_alpha": "Transparencia: aproximación alfa ordenada.", "render.check_transmission": "Transparencia: transmisión física.", + "render.children.absolute_floor": "Límite inferior absoluto", + "render.children.adaptive": "Adaptativa", + "render.children.add_look_reason": "Engade un estilo para editar o seu estado de activación e a súa intensidade.", + "render.children.all_shots": "Todas as tomas", + "render.children.bloom": "Resplandor", + "render.children.check": "Comprobar", + "render.children.check_now": "Comprobar agora", + "render.children.chromatic": "Cromático", + "render.children.compact": "Compacto", + "render.children.contrast": "Contraste", + "render.children.copy_image": "Copiar", + "render.children.copy_job": "Copiar o JSON do traballo", + "render.children.copy_request": "Copiar o JSON da solicitude", + "render.children.coverage_error": "Erro de cobertura", + "render.children.cpu": "Trazado de raios na CPU", + "render.children.denoise": "Reducir o ruído na imaxe final", + "render.children.depth": "Profundidade de camiño", + "render.children.distortion": "Distorsión", + "render.children.edge_samples": "Mostras de bordo", + "render.children.effects_banner": "Estes efectos necesitan trazado de camiños na GPU. Abrir isto nunca cambia o método.", + "render.children.enable_adaptive": "Activa os pilotos independentes para editar os límites adaptativos.", + "render.children.enable_bloom": "Activa o resplandor para editar a súa intensidade, o limiar e o raio.", + "render.children.finishing": "Acabado", + "render.children.flare": "Destello", + "render.children.fresnel": "Estratos de Fresnel", + "render.children.ghosts": "Aneis fantasma", + "render.children.glass": "Transporte no vidro", + "render.children.go_to": "Ir a {editor}", + "render.children.grain": "Gran", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Conservar o máster lineal en bruto", + "render.children.last_result": "Último: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efectos de lente", + "render.children.look_summary": "{tone} · {count} estilos", + "render.children.looks": "Estilos creativos", + "render.children.memory": "Memoria", + "render.children.method": "Método", + "render.children.minimum": "Mínimo", + "render.children.needs_cpu": "Necesita trazado de raios na CPU. Abrir isto nunca cambia o método.", + "render.children.nested": "Dieléctricos aniñados certificados", + "render.children.not_checked": "Sen comprobar", + "render.children.path": "Trazado de camiños", + "render.children.pilots": "Mostras piloto", + "render.children.preview": "Datos da vista previa", + "render.children.progressive_reason": "Escolle Progresivo para editar o seu número de mostras e a semente.", + "render.children.radius": "Raio", + "render.children.reflect_depth": "Profundidade de reflexión", + "render.children.reflect_samples": "Mostras de reflexión", + "render.children.regrade": "Recorrección de cor", + "render.children.relative_error": "Erro relativo", + "render.children.render": "Renderizado", + "render.children.render_shot": "Renderizar a toma", + "render.children.renderer": "Motor de render", + "render.children.renderers": "Motores de render", + "render.children.request": "Esta solicitude", + "render.children.reset_finishing": "Restablecer o acabado", + "render.children.review_quality": "Revisar unha especificación de calidade…", + "render.children.rgb_gain": "Ganancia RGB", + "render.children.samples": "Mostras", + "render.children.sampling": "Mostraxe", + "render.children.saturation": "Saturación", + "render.children.scene_summary": "{bodies} corpos · {triangles} triángulos", + "render.children.seed": "Semente", + "render.children.sharpening": "Enfoque", + "render.children.single_image": "Imaxe única", + "render.children.soft_focus": "Enfoque suave", + "render.children.streak": "Raia", + "render.children.strength": "Intensidade", + "render.children.studio": "Studio", + "render.children.threshold": "Limiar", + "render.children.tone": "Ton", + "render.children.under_hood": "Detalles internos", + "render.children.vignette": "Viñeta", + "render.children.workstation": "Estación de traballo", + "render.choose_look": "Escolle primeiro un estilo en Aparencias", "render.chromatic_offset": "Desprazamento cromático dos bordos", "render.clear_stage": "Quitar o escenario gardado", "render.copy_request": "Copiar JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Intensidade do destello dos realces", "render.flare_threshold": "Limiar do destello lineal da escena", "render.fold_browser": "Contraer o navegador", + "render.frame_aspect": "Proporción", + "render.frame_centre_mark": "Marca central", + "render.frame_copy_limit": "Non hai ningún nome dispoñible para a toma duplicada", + "render.frame_copy_name": "{name} copia {number}", + "render.frame_custom": "Personalizada", + "render.frame_distance_unit": " × tamaño", + "render.frame_duplicate": "Duplicar", + "render.frame_exposure": "Exposición", + "render.frame_fov": "Campo de visión", + "render.frame_guides": "Guías", + "render.frame_invalid_aspect": "Axuste predefinido de proporción non válido", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Nome", + "render.frame_output_size": "Tamaño de saída", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Retrato", "render.frame_presets": "Axustes predefinidos de encadre", "render.frame_presets_pending": "Chegará co editor de Tomas", + "render.frame_projection": "Proxección", + "render.frame_render_shot": "Renderizar esta toma", + "render.frame_rule_thirds": "Regra dos terzos", + "render.frame_safe_area": "Área segura", + "render.frame_shot_title": "Toma · {name}", + "render.frame_square": "1:1 · Cadrado", + "render.frame_tall": "9:16 · Vertical", + "render.frame_thirds": "Terzos", + "render.frame_wide": "16:9 · Panorámico", "render.gpu_depth": "Profundidade máxima de traxectoria", "render.gpu_options_help": "As mostras, a profundidade, a semente e a exposición gárdanse con esta configuración. Só se garda o método de imaxe activo.", "render.gpu_path_failed": "Trazador de traxectorias na GPU non dispoñible", @@ -2360,6 +2627,7 @@ "render.heading_results": "RESULTADOS", "render.heading_shots": "TOMAS", "render.hide_shots_label": "Ocultar a franxa de tomas", + "render.hover_look": "{object} · {look} · fai clic para seleccionar", "render.image_advanced": "Controis avanzados da GPU", "render.image_cancelled": "Render cancelado", "render.image_cancelling": "Cancelando", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Usar a aproximación uniforme sen efectos espaciais nin avanzados", "render.library_usable": "Proxeccións executables", "render.library_users": "Usado por", + "render.light_aim": "Apuntar a", "render.light_anchor_pending": "Aínda non se admite cambiar o sistema de coordenadas da luz.", + "render.light_azimuth": "Acimut", + "render.light_backend_limit": "Escolle o trazado de raios na CPU ou unha iluminación de estudio explícita na GPU para editar as luces. As luces de estudio antigas da GPU seguen sendo só de lectura.", + "render.light_behaviour": "COMPORTAMENTO", + "render.light_default_fill": "Recheo", + "render.light_default_key": "Principal", + "render.light_default_number": "Luz {number}", + "render.light_default_rim": "Contraluz", + "render.light_delete": "Eliminar", + "render.light_distance": "Distancia", + "render.light_duplicate": "Duplicar", + "render.light_elevation": "Elevación", + "render.light_gpu_punctual": "As luces puntuais e de foco están dispoñibles co trazado de raios na CPU.", + "render.light_hard_shadow": "Esta luz ten sombras duras; a suavidade do emisor non está dispoñible.", + "render.light_intensity": "Intensidade", + "render.light_missing": "Esta luz xa non existe. Selecciona unha luz no navegador.", + "render.light_place_highlight": "Colocar polo realce", + "render.light_place_pending": "A colocación polo realce necesita escoller luces na ventá gráfica e aínda non se admite.", + "render.light_position": "POSICIÓN", + "render.light_preview_help": "A vista en directo non ten sombras; as luces de área usan unha aproximación cunha mostra no centro. O render usa o modelo de luz completo.", + "render.light_product_centre": "Centro do produto", + "render.light_size": "Tamaño", + "render.light_softness": "Suavidade", + "render.light_temperature": "Temperatura de cor", + "render.light_temperature_help": "Ton aproximado de corpo negro; gárdase como RGB lineal.", + "render.light_type": "Tipo", + "render.light_type_area": "Área", + "render.light_type_point": "Puntual", + "render.light_type_spot": "Foco", + "render.light_type_sun": "Sol", + "render.light_view_only": "O solo e o silenciamento afectan a esta vista e aos seus renders; nunca se gardan no deseño.", + "render.light_world_origin": "Orixe do mundo", + "render.lighting_aim_point": "Punto de mira", + "render.lighting_apply": "Aplicar o axuste predefinido", + "render.lighting_area_samples": "Mostras de área", + "render.lighting_budget": "Orzamento", + "render.lighting_budget_help": "As cantidades son só de lectura. Engade, duplica ou elimina luces para cambialas; o método define os límites.", + "render.lighting_colour": "Cor", + "render.lighting_consent": "Substituír as luces direccionais por este axuste predefinido", + "render.lighting_custom": "Luces personalizadas", + "render.lighting_custom_help": "Ao escoller Luces personalizadas, o axuste predefinido convértese en luces editables, cada unha nunha fila da árbore.", + "render.lighting_direction": "Dirección X Y Z", + "render.lighting_direction_help": "Ou escribe unha dirección cara á luz (X Y Z).", + "render.lighting_distance_unit": " × tamaño", + "render.lighting_edge_u": "Vector do lado U", + "render.lighting_edge_v": "Vector do lado V", + "render.lighting_falloff": "Atenuación", + "render.lighting_falloff_help": "A intensidade indícase á distancia de referencia.", + "render.lighting_height": "Altura V", + "render.lighting_minimum": "Mínima", + "render.lighting_mute": "Silenciar esta luz", + "render.lighting_point": "Punto", + "render.lighting_preset": "Axuste predefinido de estudio", + "render.lighting_preset_cpu": "Os axustes predefinidos de estudio direccionais requiren trazado de raios na CPU. A iluminación da GPU usa luces explícitas de tipo Sol e Área.", + "render.lighting_preset_row": "Axuste predefinido", + "render.lighting_radiance": "Radiancia", + "render.lighting_reference": "Referencia", + "render.lighting_review_help": "Substitúe a lista de luces direccionais. Mantén as luces puntuais e de área, a contorna, as sombras, a cámara e todos os demais axustes da toma.", + "render.lighting_review_title": "Revisar o axuste predefinido de iluminación", + "render.lighting_samples_cpu": "As mostras de luz de área aplícanse ao trazado de raios na CPU.", + "render.lighting_samples_need_area": "Engade unha luz de área da CPU para definir as mostras de área.", + "render.lighting_samples_reason": "As mostras de área deben ser 1, 4, 16 ou 64.", + "render.lighting_shadow_cpu": "O interruptor de sombras require trazado de raios na CPU. O trazado de camiños na GPU sempre proxecta sombras.", + "render.lighting_shadows": "Proxectar sombras", + "render.lighting_shadows_section": "Sombras e mostraxe", + "render.lighting_shape": "Forma", + "render.lighting_solo": "Pór esta luz en solo", + "render.lighting_source": "Fonte", + "render.lighting_source_section": "Fonte de luz", + "render.lighting_spot_preserved": "Esta luz puntual conserva o seu cono de foco gardado ata que se cambie o seu tipo.", + "render.lighting_sun_distance": "A dirección do sol non depende da distancia nin do punto de mira.", + "render.lighting_unsupported": "non admitido", + "render.lighting_used": "{used} de {limit}", + "render.lighting_width": "Largura U", + "render.lighting_world_position": "Mundo X Y Z", "render.lights_fixed_to": "Luces fixadas a", "render.linear_help": "Conserva un máster RGBA de coma flotante de 32 bits antes da eliminación de ruído, a exposición, o mapeado tonal, os filtros e o telón de fondo. sRGB lineal/D65; premultiplicado pola cobertura. A memoria adicional admítese de forma explícita. Sen compresión nin pasadas adicionais.", "render.linear_retain": "Conservar o EXR lineal en bruto", "render.linear_unavailable": "O fotograma completado seleccionado non ten ningún EXR conservado. Activa a conservación antes de renderizar; un PNG anterior non pode recuperar a radiancia en bruto.", "render.look_add": "Engadir estilo de cor", + "render.look_applied": "Aplicouse {name}", "render.look_bleach": "Inspirado no bleach bypass", "render.look_cool": "Fría", "render.look_cyanotype": "Inspirado na cianotipia", "render.look_faded": "Copia desvaída", + "render.look_material": "Material", "render.look_mono": "Monocromo", + "render.look_pasted": "Estilo pegado", "render.look_sepia": "Sepia", "render.look_strength": "Intensidade do estilo", "render.look_teal_orange": "Verde azulado / laranxa", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Avanzado", "render.output_backend_ready": "Listo", "render.output_check": "Comprobar", + "render.output_check_failed": "Comprobar · erro", "render.output_check_help": "Executa Comprobar para revisar esta solicitude sen iniciar un render.", "render.output_check_summary": "Comprobar · Notas: {count}", "render.output_copy": "Copiar", + "render.output_copy_report": "Copiar o informe", "render.output_destination": "Destino", "render.output_destination_help": "Escolle o ficheiro de destino ao exportar a imaxe completada.", "render.output_dimensions": "Tamaño", @@ -2604,6 +2952,7 @@ "render.output_fit": "Axustar", "render.output_format": "Formato", "render.output_format_status": "O PNG é a imaxe de visualización completada exacta. O EXR opcional da GPU conserva a radiancia FLOAT32 lineal da escena en bruto antes dos filtros de visualización. A exportación nunca substitúe a caché da vista previa nin un traballo diferente.", + "render.output_grading": "Corrección de cor", "render.output_image": "Imaxe", "render.output_largest": "{width} × {height} máx.", "render.output_lock_ratio": "Bloquear a proporción de aspecto", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "agardando polo contrato de calidade", "render.output_ratio_refused": "A dimensión ligada supera o límite de tamaño deste método. Consérvase o tamaño anterior; desbloquea a proporción ou escolle un valor menor.", "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_ok": "Tamaño correcto · {width} × {height} px", "render.output_swap_orientation": "Intercambiar largura e altura", "render.output_tools_inspector": "Edita o tamaño e o formato no inspector de Saída.", @@ -2629,6 +2980,7 @@ "render.page_output": "Saída", "render.page_scene": "Escena", "render.paint_label": "Pintura", + "render.painting_with": "Pintando con {name}", "render.passive_stage": "Chan e escenario pasivo", "render.passive_stage_help": "Os paneis pasivos finitos pertencen só ás imaxes renderizadas. Non engaden corpos CAD nin masa. A colocación queda fixada despois de aplicar; rexéitanse os paneis que intersequen cos límites do produto. A CPU non ofrece iluminación de rebote difusa.", "render.perspective": "Cámara en perspectiva", @@ -2650,6 +3002,7 @@ "render.pick_help": "Escolle Corpo, Cara ou Compoñente. Fai clic no espazo baleiro para borrar a escolla; Shift ou Ctrl engade ou alterna. Os realces só aparecen na vista de produto en directo.", "render.pick_label": "Escoller", "render.pick_up_label": "Coller", + "render.pick_up_prompt": "Fai clic nun obxecto para coller o seu estilo", "render.pick_waiting": "A escolla de produto aínda non é compatible.", "render.post_contrast": "Contraste", "render.post_gain_b": "Ganancia do azul", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Distorsión radial", "render.radial_fit": "Encadrar o produto", "render.radial_save": "Gardar toma", + "render.recent_looks": "Estilos recentes", "render.regrade_action": "Corrixir a cor de novo do máster en bruto (sen eliminación de ruído)", "render.regrade_help": "Crea un PNG separado a partir do máster en bruto completado que está seleccionado, usando estes controis de presentación. Non traza raios, non cambia o tamaño, a cámara nin as luces capturados e non sobrescribe o EXR. A eliminación de ruído non se volve aplicar: os másters en bruto non conservan as súas guías. O derivado segue sendo exportable cos mesmos controis da tarefa.", "render.regrade_label": "Recorrección de cor", "render.reload_settings": "Volver cargar os axustes gardados", + "render.remove_override": "Quitar a anulación", + "render.replace_all": "Substituír todo", "render.replace_ground": "Confirmar a substitución do escenario existente", "render.replace_label": "Substituír", "render.replace_saved_surface": "Substituír unha vinculación gardada existente para este destino", + "render.replace_with": "Substituír por", "render.restore_camera": "Restaurar a cámara gardada", "render.result_actual": "100%", "render.result_actual_help": "Un píxel da imaxe por píxel da pantalla.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Quitar a vinculación gardada", "render.saved_surfaces": "Vinculacións de superficie gardadas", "render.saved_surfaces_help": "Os mapas integrados fixados seguen a instancia seleccionada en milímetros locais. Un destino substituído ou que falta é un erro, nunca unha reasignación automática.", + "render.scene_all_shots": "Todas as tomas ({count})", "render.scene_apply": "Aplicar o Studio revisado", + "render.scene_batch_reason": "Todas as tomas necesita a cola de renderizado das tomas gardadas, que aínda non está dispoñible.", + "render.scene_check_now": "Comprobar agora", + "render.scene_check_passed": "Comprobación superada · {notes} notas", + "render.scene_check_stale": "Comprobación desactualizada · comproba de novo", + "render.scene_check_unchecked": "Aínda sen comprobar · 0 notas", "render.scene_consent": "Confirmar a substitución da iluminación, o contorno, a exposición e o escenario", + "render.scene_light_summary": "{environment} · {lights} luces", + "render.scene_look_summary": "{looks} estilos · {overrides} anulacións", + "render.scene_open_check": "Abrir Comprobar", "render.scene_preserved_fields": "Consérvanse a cámara, a mostraxe, as dimensións, as vinculacións ópticas, a xeometría CAD e os materiais de enxeñaría.", "render.scene_preserves_design": "Compón a iluminación e un escenario pasivo para o suxeito visible. A xeometría, os materiais de enxeñaría e a cámara permanecen á parte.", + "render.scene_ready": "Listo para renderizar", + "render.scene_reminder": "Escena conserva o deseño. A xeometría, as propiedades de enxeñaría e o CAD nunca se cambian desde Render.", + "render.scene_reminders": "Lembretes", + "render.scene_renderer_ready": "{renderer} listo", "render.scene_replaces": "Substitúe", "render.scene_review": "Resolver e revisar", "render.scene_review_help": "Revisa a composición nativa completa antes de aplicala. As especificacións SCENE avanzadas sen vincular non se substitúen por outras.", "render.scene_scope_details": "Alcance da composición", + "render.scene_stages": "Etapas", "render.search_appearances": "Buscar {count} aparencias", "render.setup_legacy_environment": "Esta configuración antiga usa a rotación ou a intensidade do contorno herdadas. Eses valores consérvanse; conviértenos explicitamente antes de gardar unha tarefa de imaxe completa.", "render.setup_name": "Nome da configuración", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "CÁMARA DO RENDERIZADO", "render.shell_tab_lighting": "ILUMINACIÓN", "render.shell_tab_scene": "ESCENA DO RENDERIZADO", + "render.shot_aperture": "Apertura", + "render.shot_distance": "Distancia", + "render.shot_dof": "Profundidade de campo", "render.shot_duplicate": "Duplicar a toma gardada", + "render.shot_edit_output": "Editar en Saída", + "render.shot_exposure_reason": "A exposición necesita trazado de raios na CPU ou trazado de camiños na GPU.", + "render.shot_exposure_section": "Exposición", + "render.shot_focus_distance": "Distancia de enfoque", + "render.shot_fov_reason": "O campo de visión necesita proxección en perspectiva.", + "render.shot_frame_subject": "Encadrar o suxeito", + "render.shot_invalid_camera": "Os valores da cámara deben ser finitos e estar dentro do intervalo.", + "render.shot_lens": "Lente", + "render.shot_lens_enable_reason": "Activa a profundidade de campo para editar a apertura e a distancia de enfoque.", + "render.shot_lens_gpu_reason": "A profundidade de campo necesita trazado de camiños na GPU.", + "render.shot_lens_perspective_reason": "A profundidade de campo necesita proxección en perspectiva.", + "render.shot_look_selection": "Mirar cara á selección", "render.shot_manager": "Tomas gardadas", "render.shot_new_name": "Nome da toma nova", "render.shot_remove": "Quitar a toma gardada", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Quitar a toma gardada?", "render.shot_rename": "Renomear a toma gardada", "render.shot_saved_only": "Garda primeiro o borrador actual. Duplicar e renomear conservan exactamente as opcións gardadas e as referencias aos recursos.", + "render.shot_selection_reason": "Selecciona un corpo ou unha cara cara aos que apuntar.", + "render.shot_size": "Tamaño", + "render.shot_target": "Destino", "render.shots_camera_changed": "A cámara cambiou desde que se gardou esta toma", "render.shots_name_help": "Escribe un nome único para a toma actual.", "render.shots_no_result": "Aínda sen renderizado", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "aínda non se admite: a selección de produtos e a mostraxe de aparencias", "render.wait_replace": "aínda non se admite: substituír todos os usos dunha aparencia", "render.wait_solo": "aínda non se admite: a visibilidade das luces e os controis de solo", + "render.wear_applies_to": "Aplícase a", + "render.wear_layer_count": "{count} capas", + "render.wear_layer_one": "{count} capa", + "render.wear_layers": "Capas", + "render.wear_no_layers": "Sen capas", + "render.wear_selected_layer": "Capa seleccionada", + "render.wear_title": "Desgaste", "sample.bracket.about": "Pestana base con pestanas de aresta e un dobradillo cunha regra de 2 mm", "sample.bracket.name": "Escuadra de chapa", "sample.enclosure.about": "Caixa baleirada cun beizo, saíntes para parafusos, un apoio e un encaixe a presión", @@ -3030,6 +3426,7 @@ "select an axis": "selecciona un eixe", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "selecciona primeiro un eixe, unha aresta recta ou circular, ou unha cara de cilindro, cono ou toro", "select bodies first": "selecciona primeiro os corpos", + "select components first": "selecciona primeiro os compoñentes", "select corner points, or the two lines of one corner": "selecciona puntos de esquina ou as dúas liñas dunha esquina", "select one corner point, or the two lines of one corner": "selecciona un punto de esquina ou as dúas liñas dunha esquina", "select sheet edges first": "selecciona primeiro as arestas da chapa", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Fai dobre clic nun compoñente para activalo, ou para editar in situ un compoñente ligado", "settings.frame_rate_details_note": "Engade un gráfico dos tempos dos últimos fotogramas cos triángulos debuxados por riba, un histograma dos tempos de fotograma e o que debuxou a GPU. Fai clic nas imaxes por segundo da vista para mostralos ou ocultalos.", "settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista.", + "settings.insert_lists_open_designs": "Inserir deseño lista primeiro os deseños abertos noutras lapelas", "settings.measure_axis_distances": "Mostrar as distancias en X/Y/Z", "settings.measure_axis_distances_help": "Mostra as compoñentes da distancia segundo os eixes do mundo xunto á distancia total mentres Medir está aberto.", "settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie SolidCraft", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Desafixar", "sketch is missing": "falta o bosquexo", "sketch.degrees_of_freedom": "{count} graos de liberdade", + "sketch.dimension_added_driven": "O bosquexo xa determina esa cota, así que se engadiu como cota conducida (de referencia)", + "sketch.dimension_applied": "Cota aplicada", + "sketch.dimension_applied_defined": "Cota aplicada; definiuse o parámetro `{name}`", + "sketch.dimension_applied_renamed": "Cota aplicada; parámetro chamado `{name}`", "sketch.dof_left": "Quedan {count} graos de liberdade", "sketch.dof_left_one": "Queda {count} grao de liberdade", "sketch.draw_curves": "Debuxar como {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Usar o ficheiro de tipo de letra {path}", "solid": "sólido", "status.editing": "Editando {name}", + "status.navigation": "Asignación do rato: {mapping}", + "status.navigation_tip": "Como move a vista o rato. Fai clic para escoller outra asignación", "status.under_constrained": "Subrestrinxido ({count} graos de liberdade)", "status.under_constrained_one": "Subrestrinxido ({count} grao de liberdade)", "status.units_tip": "As unidades do deseño. Fai clic para cambialas", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Mantén as súas operacións e os cartafoles aniñados no historial", "timeline.delete_folders_with_features_tip": "Elimina os cartafoles con todas as operacións que conteñen e os seus cartafoles aniñados", "timeline.delete_many": "Eliminar {count} operacións seleccionadas", + "timeline.edited_rolled_back": "{name} cambiou, pero está despois do marcador do historial, así que non se constrúe: move o marcador despois del para ver o resultado", "timeline.feature_error": "Erro: {message}", "timeline.feature_hint": "Dobre clic para editar · arrastra para reordenar · clic dereito para máis", + "timeline.feature_rolled_back": "Retrocedida: está despois do marcador do historial, así que non se constrúe", "timeline.feature_warning": "Aviso: {message}", "timeline.group": "Cartafol de operacións novo", "timeline.move_many": "Mover elementos da liña de tempo", "timeline.move_to_group": "Mover operacións ao cartafol", "timeline.rename_group": "Renomear o cartafol de operacións", "timeline.reorder_many": "Reordenar operacións", + "timeline.suppress_features": "Suprimir as operacións", "timeline.suppress_folders_tip": "Suprime ou deixa de suprimir todas as operacións dos cartafoles e dos seus cartafoles aniñados", "timeline.ungroup": "Quitar os cartafoles de operacións", + "timeline.unsuppress_features": "Deixar de suprimir as operacións", "to the face": "á cara", "to the next face": "ata a seguinte cara", "toolbar.customize_note": "Lapela {tab}. Marque un comando para engadir o seu botón á barra de ferramentas. Tamén pode arrastrar unha fila do despregable dun panel á barra, arrastrar un botón fóra da barra para retiralo ou usar a chincheta dunha fila do despregable.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Calibrar o teu rato 3D corrixe os eixes trocados ou invertidos e axusta a súa velocidade e sensación.", "ui.3d_mouse_offer_never": "Non mostrar de novo", "ui.3d_mouse_offer_swap": "Cambiar a Hidraw (controlador directo)", + "ui.3d_mouse_orbit": "Órbita", + "ui.3d_mouse_orbit_classic": "Clásica", + "ui.3d_mouse_orbit_tip": "Trazada: o rato 3D xira a vista arredor da parte do modelo que está no medio da vista cando comeza o xiro, marcada cunha bóla tenue. Clásica: arredor do centro da vista", + "ui.3d_mouse_orbit_traced": "Trazada", "ui.3d_mouse_reading": "Lectura", "ui.3d_mouse_reset_axes": "Restablecer asignación de eixes", "ui.3d_mouse_reset_view": "Restablecer vista", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantén a posición e despois sóltao.", "ui.3d_mouse_switch_cap": "Calibrar como retorno ao centro", "ui.3d_mouse_switch_trackball": "Calibrar como trackball", + "ui.3d_mouse_use_direct": "Usar controlador directo", + "ui.3d_mouse_use_direct_tip": "Ler o rato 3D directamente (hidraw) en vez de a través de spacenavd.", "ui.3d_mouse_view_axis": "Eixe da vista", "ui.3d_mouse_view_off": "Desactivado", "ui.3d_mouse_view_off_tip": "O perfil do controlador do rato 3D desactiva este eixe, así que o seu movemento non move a vista. Se escolles aquí o movemento doutro eixe, intercámbianse e ese movemento deixa de mover a vista.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Cancelar", "ui.canvases": "Lenzos", "ui.center": "Centro", + "ui.center_arc_follows_cursor": "O arco por punto central segue a dirección do cursor", + "ui.center_arc_follows_cursor_tip": "Un arco por punto central vai no sentido horario ou antihorario segundo como o cursor xira arredor do seu centro. Desactivado: sempre no sentido antihorario desde o inicio.", "ui.centerline": "Liña central", "ui.chain": "Cadea", "ui.chamfer": "Chafrán", @@ -3570,6 +3986,7 @@ "ui.circle": "Círculo", "ui.circular_pattern": "Patrón circular", "ui.clear": "Borrar", + "ui.clear_print_face": "Quitar cara de impresión", "ui.clear_recent": "Borrar recentes", "ui.clear_the_selection": "Borrar a selección", "ui.click_selects_whole_bodies": "Un clic selecciona corpos enteiros", @@ -3604,6 +4021,15 @@ "ui.constant": "Constante", "ui.constraints_conflict": "Conflito de restricións", "ui.construction": "Construción", + "ui.construction.angle_hint": "Recta de construción: fai clic nun punto e despois escribe un ángulo ou escolle unha dirección", + "ui.construction.bisector_hint": "Bisectriz: escolle dúas rectas que se crucen e despois a solución desexada", + "ui.construction.common_hint": "Tanxente común: escolle dous círculos/arcos e despois a solución desexada", + "ui.construction.contact_hint": "Tanxente: escolle un círculo/arco ou unha spline/cónica aberta e despois o punto de tanxencia (curvas de forma libre: extremos)", + "ui.construction.external_hint": "Tanxente: escolle un círculo ou arco, un punto exterior e despois a solución desexada", + "ui.construction.free_hint": "Recta de construción: fai clic en dous puntos; clic dereito para reiniciar", + "ui.construction.point_hint": "Recta de construción: fai clic no punto polo que debe pasar", + "ui.construction.reference_hint": "Recta de construción: escolle unha recta e despois un punto; clic dereito para cambiar a referencia", + "ui.construction.ribbon_label": "Liñas aux.", "ui.context_menu": "Menú contextual", "ui.continuity_g0": "G0 aresta", "ui.continuity_g1": "G1 tanxente", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Escala final", "ui.entities": "Entidades", "ui.equal": "Igual", + "ui.equation_end": "Fin", + "ui.equation_explicit": "Explícita", + "ui.equation_form": "Forma", + "ui.equation_note": "A variable é un número sen unidade: sin(t) tómaa en radiáns. Un resultado sen unidade está na unidade do deseño.", + "ui.equation_parametric": "Paramétrica", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Raio equivalente", "ui.expand": "Expandir", "ui.exportDxf": "Exportar DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "DESEÑO", "ui.heading_draft": "ÁNGULO DE DESMOLDAXE", "ui.heading_edit": "EDITAR", + "ui.heading_equation_curve": "CURVA DE ECUACIÓN", "ui.heading_extrude": "EXTRUÍR", "ui.heading_fillet": "ARREDONDAMENTO", "ui.heading_finish_editing": "REMATAR A EDICIÓN", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Importar…", "ui.infinite": "Infinito", "ui.insert": "Inserir", + "ui.insert_design.browse": "Explorar…", + "ui.insert_design.open_designs": "Deseños abertos", + "ui.insert_design.saved_file": "{name} ten cambios sen gardar, así que se inseriu o seu ficheiro gardado. Gárdao e despois usa Actualizar as ligazóns para incorporar os cambios.", + "ui.insert_design.unsaved": "Cambios sen gardar", "ui.inspect": "Inspeccionar", "ui.intersect": "Intersecar", "ui.isolate": "Illar", @@ -3994,12 +4433,29 @@ "ui.polygon": "Polígono", "ui.preferences": "Preferencias", "ui.press_a_key": "preme unha tecla…", + "ui.print_face_cleared": "{body}: cara de impresión quitada", + "ui.print_face_input": "Cara de impresión", + "ui.print_face_one_body": "Unha cara de impresión pertence a un só corpo: envía ese corpo só para cambiala", + "ui.print_face_pick": "Selecciona unha cara plana dun corpo sólido para facela a cara de impresión", + "ui.print_face_set": "{body}: cara de impresión definida; Enviar ao laminador apoia o corpo sobre ela", "ui.profile": "Perfil", "ui.profile_plane": "Plano do perfil", "ui.profiles": "Perfís", "ui.project": "Proxectar", "ui.project_face_edges_sketch": "Proxectar as arestas da cara en bosquexos novos", "ui.properties": "Propiedades", + "ui.properties.copy_matrix": "Copiar a matriz", + "ui.properties.first_moments": "Momentos de primeira orde", + "ui.properties.inertia": "Momento de inercia", + "ui.properties.inertia_note": "Respecto ao centro de masas, nos eixes do mundo (g·{unit}²)", + "ui.properties.inertia_unavailable": "Non dispoñible: a malla dun corpo non está pechada.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Medindo…", + "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", "ui.pull_direction": "Dirección de extracción", "ui.quality": "Calidade", "ui.quantity": "Cantidade", @@ -4045,6 +4501,7 @@ "ui.revolve": "Revolución", "ui.ribbon_exit": "Saír", "ui.ribbon_labels": "Mostrar texto baixo as iconas da cinta", + "ui.ribbon_menus_skip_pinned": "Deixar as ordes fixadas fóra dos menús despregables", "ui.ribbon_pin_footer": "As ordes fixadas móstranse na cinta. Pasa o cursor sobre unha fila para fixala ou soltala.", "ui.ribbon_workspace": "Espazo de traballo", "ui.right": "Dereita", @@ -4062,6 +4519,7 @@ "ui.select": "Seleccionar", "ui.selectOther": "Seleccionar outro…", "ui.selectTangentChain": "Seleccionar cadea tanxente", + "ui.select_all": "Seleccionar todo", "ui.selection": "Selección", "ui.selection_modifiers": "Modificadores de selección", "ui.selection_shift_add_only": "Maiús+clic engade sen quitar", @@ -4098,7 +4556,11 @@ "ui.sketch": "Bosquexo", "ui.sketch_dimension": "Cota do bosquexo", "ui.sketch_grid": "Grella do bosquexo", + "ui.sketch_intersect_geometry": "Xeometría", + "ui.sketch_intersect_projection_link": "Ligazón de proxección", + "ui.sketch_intersect_selection_filter": "Filtro de selección", "ui.sketch_modifiers": "Modificadores de bosquexo", + "ui.sketch_project_geometry": "Xeometría", "ui.sketch_scale": "Escala do bosquexo", "ui.sketches": "Bosquexos", "ui.slice": "Laminar", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Xestionar", "ui.tab_sheet_metal": "Chapa", "ui.tab_view": "Vista", + "ui.tab_weldments": "Soldadura", "ui.tangent": "Tanxente", "ui.taper_angle": "Ángulo de conicidade", "ui.tapped": "Roscado", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Ángulo da punta", "ui.to_object": "Ata o obxecto", "ui.tool_direction": "Dirección da ferramenta", + "ui.toolbar_tabs_hidden": "Todas as lapelas da barra de ferramentas están ocultas. Fai clic en + ou fai clic co botón dereito aquí para mostrar unha.", "ui.top": "Superior", "ui.top_diameter": "Diámetro superior", "ui.top_radius": "Raio superior", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Centro da vista", "ui.viewport_status_clear": "Desactivar {name}", "ui.viewport_status_clear_hint": "Desactivar {name} · Esc", + "ui.viewport_status_edges": "{count} arestas", + "ui.viewport_status_from": "desde {direction}", "ui.viewport_status_key": "Mostrar a lenda de {name}", "ui.viewport_status_minimum_radius": "Raio mínimo", "ui.viewport_status_pairs": "{count} pares", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Sombreado con arestas", "ui.visual_style": "Estilo visual", "ui.visual_wireframe": "Arames", + "ui.wheel_zoom_speed": "Velocidade do zoom coa roda do rato", "ui.width": "Largura", "ui.window_system": "Sistema de xanelas", "ui.window_system_auto": "Automático", @@ -4240,7 +4707,9 @@ "update.button": "Actualizar", "update.button_version": "Actualizar a {version}", "update.check_automatically": "Buscar actualizacións automaticamente", + "update.check_for_updates": "Buscar actualizacións", "update.check_now": "Comprobar agora", + "update.check_unavailable": "A busca de actualizacións non está dispoñible para esta instalación.", "update.checking": "Comprobando…", "update.copy": "Copiar", "update.dmg_opened": "A imaxe de disco está aberta e verificada: arrastra SolidCraft a Aplicacións, substituíndo a copia antiga, e despois reinicia SolidCraft.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} está instalado. Reinicia para usalo.", "update.installer_started": "O instalador iníciase cando se pecha SolidCraft.", "update.later": "Lembrarmo máis tarde", + "update.managed": "Actualización dispoñible: actualiza SolidCraft en {manager}", "update.no_notes": "Esta versión non ten notas.", "update.package_hint": "Esta copia veu dun {kind}. Descarga o paquete novo e despois instálao con:", "update.prefs_dev": "Esta é unha compilación de desenvolvemento ({version}): non busca actualizacións.", "update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): executa {command} ou usa o teu centro de software. SolidCraft non comproba.", "update.prefs_label": "Actualizacións", + "update.prefs_managed": "Esta é a versión {version} de SolidCraft, xestionada por {manager}. Unha comprobación pídelle a GitHub a última versión e non envía nada máis; actualiza SolidCraft en {manager}.", "update.prefs_note": "Esta é a versión {version} de SolidCraft ({kind}). Unha comprobación pídelle a GitHub a última versión e non envía nada máis.", "update.prefs_web": "Recarga a páxina para obter a última versión da aplicación web. Se a rede non está dispoñible ou a actualización non pode rematar de descargarse a tempo, ábrese a versión gardada; unha descarga en curso continúa para a seguinte recarga.", + "update.refresh_checking": "Buscando a versión máis recente…", + "update.refresh_fallback": "Non se puido buscar unha versión máis recente; actualizando a {version}, atopada antes.", + "update.refresh_selected": "Actualizando a {version}.", "update.release_page": "Abrir a páxina da versión", "update.restart": "Reiniciar agora", "update.restart_later": "Máis tarde", diff --git a/assets/locales/he.json b/assets/locales/he.json index 5065f0bcae..c3a0843993 100644 --- a/assets/locales/he.json +++ b/assets/locales/he.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": "התמחות", @@ -775,6 +778,8 @@ "at the face": "על הפאה", "bom.group_children": "תת-חלקים בשורת החלק שלהם", "browser.body_name": "גוף{number}", + "browser.calibrate_click_a": "לחצו על התמונה כדי לבחור את נקודה A, או הקלידו את הקואורדינטות שלה.", + "browser.calibrate_click_b": "לחצו על התמונה כדי לבחור את נקודה B, או הקלידו את הקואורדינטות שלה.", "browser.calibrate_title": "כיול: {name}", "browser.capture_many": "{count} רכיבים הוזזו, אך המיקומים שלהם לא נלכדו.", "browser.capture_note": "לכידה שומרת את המיקום החדש; החזרה מחזירה את המיקום הקודם.", @@ -817,6 +822,7 @@ "cmd.appearance_pick_up": "בחירת מראה", "cmd.appearance_replace": "החלפת מראה", "cmd.assembly_bom": "רשימת חלקים", + "cmd.body.print_face": "פאת הדפסה", "cmd.body_rename": "שינוי שם גוף", "cmd.browser_collapse_all": "כיווץ הכול", "cmd.browser_expand_all": "הרחבת הכול", @@ -1101,6 +1107,16 @@ "cmd.sheet_refold": "קיפול מחדש", "cmd.sheet_rules": "רשימת כללי פח", "cmd.sheet_unfold": "פתיחת קיפול", + "cmd.sketch.construction_line": "ישר עזר", + "cmd.sketch.construction_line.angle": "ישר עזר בזווית", + "cmd.sketch.construction_line.bisector": "חוצה זווית", + "cmd.sketch.construction_line.horizontal": "ישר עזר אופקי", + "cmd.sketch.construction_line.parallel": "ישר עזר מקביל", + "cmd.sketch.construction_line.perpendicular": "ישר עזר ניצב", + "cmd.sketch.construction_line.tangent_at": "משיק בנקודה על העקומה", + "cmd.sketch.construction_line.tangent_external": "משיק דרך נקודה חיצונית", + "cmd.sketch.construction_line.tangent_two": "משיק משותף", + "cmd.sketch.construction_line.vertical": "ישר עזר אנכי", "cmd.sketch_arc3d": "קשת תלת־ממדית", "cmd.sketch_arc_center_point": "קשת מרכז ונקודה", "cmd.sketch_arc_tangent": "קשת משיקה", @@ -1389,7 +1405,18 @@ "help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.", "help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.", "help.pointcloud.insert": "הוספת סריקה בפורמט PLY, XYZ או PTS כענן נקודות לצד המודל. משמש כעזר למידול על סמך נתוני מדידה.", + "help.sketch.construction_line": "יצירת ישר עזר אינסופי דרך שתי נקודות, לתכנון הסקיצה בלי לפצל את הפרופילים.", + "help.sketch.construction_line.angle": "יצירת ישר עזר אינסופי בזווית מציר X של הסקיצה. יש לבחור נקודה ולהקליד זווית או לבחור כיוון.", + "help.sketch.construction_line.bisector": "יש לבחור שני ישרים נחתכים בסקיצה ואז אחד משני חוצי הזווית. התוצאה עוקבת אחר עריכת ישרי המקור.", + "help.sketch.construction_line.horizontal": "יצירת ישר עזר אופקי אינסופי דרך נקודה. האילוץ האופקי נשמר בעריכות מאוחרות יותר.", + "help.sketch.construction_line.parallel": "יש לבחור ישר בסקיצה ואז למקם ישר עזר אינסופי מקביל. לחיצה ימנית מאפשרת לבחור ייחוס אחר.", + "help.sketch.construction_line.perpendicular": "יש לבחור ישר בסקיצה ואז למקם ישר עזר אינסופי ניצב. לחיצה ימנית מאפשרת לבחור ייחוס אחר.", + "help.sketch.construction_line.tangent_at": "יצירת משיק בנקודה על מעגל או קשת מעגלית, או בנקודת קצה של ספליין פתוח או חתך חרוט פתוח. יש לבחור את העקומה ואז את נקודת ההשקה.", + "help.sketch.construction_line.tangent_external": "יש לבחור מעגל או קשת מעגלית, נקודה חיצונית ואז פתרון למשיק. התצוגה המקדימה מציגה רק נקודות השקה על הקשת המצוירת.", + "help.sketch.construction_line.tangent_two": "יש לבחור שני מעגלים או קשתות מעגליות ואז משיק משותף בתצוגה המקדימה. נקודות ההשקה חייבות להיות על העקומות המצוירות.", + "help.sketch.construction_line.vertical": "יצירת ישר עזר אנכי אינסופי דרך נקודה. האילוץ האנכי נשמר בעריכות מאוחרות יותר.", "help.sketch.equation_curve": "משרטט עקומה מנוסחאות על פני טווח: x(t) ו־y(t), או y = f(x). הנוסחאות יכולות להשתמש בפרמטרים והעקומה עוקבת אחריהם: לגל סינוס, לספירלה או לאבולבנטה של שן גלגל שיניים.", + "help.sketch.inspect": "פותח דוח על הסקיצה הפתוחה: כמה דרגות חופש נותרו והאם היא מאולצת במלואה. השתמשו בו כדי לגלות מה עדיין זז לפני שמסיימים את הסקיצה.", "help.solid.align": "הזזת הגופים שנבחרו כך שנקודה, פאה או מישור שלהם יפגשו נקודה, פאה או מישור במקום אחר. מתאים להצמדת חלק לחלק אחר בלי למדוד את המרחק.", "help.solid.chamfer": "חיתוך המקצועות שנבחרו בשיפוע ישר במרחק שנקבע. כך שוברים מקצועות חדים או מקלים על הכנסה של פין או בורג.", "help.solid.combine": "איחוד גופים, חיסורם מגוף יעד או השארת הנפח המשותף להם בלבד, עם אפשרות לשמור את גופי הכלי. מתאים למיזוג חלקים, לחיתוך צורה אחת בעזרת אחרת או למציאת החפיפה בין שני גופים.", @@ -1592,6 +1619,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} שנ'", @@ -1744,6 +1775,11 @@ "not a move": "זו אינה הזזה", "not a number": "לא מספר", "open a sketch first": "יש לפתוח סקיצה תחילה", + "parameters.apply_live": "להחיל כל שינוי ביציאה מהשדה שלו", + "parameters.apply_live_tip": "כבוי: השינויים ממתינים עד ל„החל” או „אישור”, ואז התכן נבנה מחדש פעם אחת עבור כולם. „ביטול” או Escape משליכים אותם.", + "parameters.held": "{count} שינויים ממתינים ל„החל”", + "parameters.held_hint": "עריכת ביטויים ויחידות. השינויים ממתינים ל„החל” או ל„אישור”.", + "parameters.held_one": "שינוי אחד ממתין ל„החל”", "parts.automatic_name": "אוטומטי", "parts.dimension.across_flats": "מידת מפתח", "parts.dimension.bore": "קוטר קדח", @@ -1766,6 +1802,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", @@ -1818,6 +1856,8 @@ "prefs.bottom_centre": "למטה במרכז", "prefs.bottom_right": "למטה מימין", "prefs.browser": "עץ התכן", + "prefs.browser_eye_left": "סמל העין ליד חץ הקיפול", + "prefs.browser_eye_left_note": "שורות עץ התכן מציגות את סמל העין ליד חץ הקיפול, כמו קודם, במקום בעמודה בקצה הימני של השורות.", "prefs.browser_reveal_on_hover": "הצגה בריחוף", "prefs.browser_show_note": "״מוצג״ משאיר את הדפדפן על המסך. ״הצגה בריחוף״ מסתיר אותו עד שהמצביע מגיע למקומו או ללשונית הדקה בקצהו השמאלי, והוא נשאר פתוח כל עוד המצביע בתוכו או שתפריט ממנו פתוח.", "prefs.browser_shown": "מוצג", @@ -1868,6 +1908,8 @@ "prefs.graphics_card_restart": "ייכנס לתוקף אחרי הפעלה מחדש של SolidCraft. WGPU_ADAPTER_NAME גובר על בחירה זו.", "prefs.graphics_note": "איזון בין מהירות לחלקות. כל בחירה מציגה את השפעתה על דוגמה.", "prefs.graphics_presets_note": "הגדרות קבועות מראש קובעות יחד את הבחירות שלמטה.", + "prefs.hide_constraint_glyphs_zoomed_out": "הסתרת סמלי אילוצים בהקטנה", + "prefs.hide_constraint_glyphs_zoomed_out_note": "סמלי האילוצים נעלמים כשהשרטוט מוקטן מספיק כדי שלא יעמיסו על התצוגה. כבוי: הם נשארים בגודל מסך קבוע בכל זום.", "prefs.history": "ציר הזמן", "prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה", "prefs.history_origin_bodies_note": "ההיסטוריה משמאל או מתחת לדפדפן מתחילה בתיקיית הגופים של הרכיב הפעיל ובראשית, עם סמלי העין והתפריטים כמו בדפדפן. ההיסטוריה למטה לא משתנה.", @@ -1906,6 +1948,9 @@ "prefs.max_file_size_step": "קובצי STEP וייבוא", "prefs.max_file_size_web": "יישום הדפדפן פותח קובצי STEP וייבוא עד 512 MB וקובצי תכן עד 256 MB: לשונית דפדפן אינה יכולה להכיל קבצים גדולים יותר. ביישום למחשב ניתן להעלות מגבלות אלה.", "prefs.megabytes": "MB", + "prefs.menu_grid": "סמלים גדולים", + "prefs.menu_list": "רשימה", + "prefs.menus": "תפריטים", "prefs.minutes": "דקות", "prefs.minutes_suffix": " דק'", "prefs.mouse": "עכבר ומשטח מגע", @@ -1925,10 +1970,16 @@ "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": "שדה ראייה", "prefs.placement": "מיקום", "prefs.prefer_free_sketch_points": "העדפת נקודות סקיצה ניתנות להזזה כשנקודות חופפות", "prefs.preview_choices": "תצוגה מקדימה של הבחירות שלך", + "prefs.print_face_highlight": "הדגשת פאת ההדפסה", + "prefs.print_face_highlight_tip": "עד כמה חזק צובעים ומקיפים את פאת ההדפסה של גוף בתצוגה. ‏0 % אינו מציג הדגשה.", "prefs.profile_active": "פרופיל פעיל", "prefs.profile_applied": "הפרופיל הוחל.", "prefs.profile_changed": "ההגדרות השתנו מאז שהפרופיל נשמר. „שמירה“ מכניסה אותן לפרופיל.", @@ -1950,6 +2001,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} שמורים", @@ -1963,6 +2016,8 @@ "prefs.search_empty": "אין הגדרות תואמות", "prefs.select": "בחירה", "prefs.select_one_item": "בחירת פריט אחד", + "prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת", + "prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.", "prefs.selecting": "בחירה", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב", @@ -1974,11 +2029,14 @@ "prefs.sketch_align_points_note": "בזמן השרטוט, נקודה מתיישרת אופקית עם נקודת סקיצה אחת ואנכית עם אחרת בו-זמנית, עם קו עזר מנוקד, והלחיצה שומרת את שני היישורים. כבוי: רק עם הנקודה הקודמת, ציר אחד בכל פעם.", "prefs.sketch_conflict_colour": "גאומטריה סותרת בצבע הסתירה", "prefs.sketch_conflict_colour_note": "כשאילוצי סקיצה סותרים, העקומות והנקודות שאילוץ כושל מחזיק מצוירות גם הן בצבע הסתירה, לא רק הסמל או המידה שלו. כבוי: הן שומרות על צבעיהן הרגילים.", + "prefs.sketch_construction_lines": "כלי ישרי עזר מורחבים", "prefs.sketch_exit_view": "בסיום סקיצה", "prefs.sketch_exit_view.keep": "שמירה על התצוגה הנוכחית", "prefs.sketch_exit_view.previous": "חזרה לתצוגה הקודמת", "prefs.sketch_exit_view.unless_rotated": "חזרה אלא אם התצוגה סובבה", "prefs.sketch_exit_view_note": "לאן התצוגה עוברת אחרי סיום סקיצה, ביטול או Esc כפול.", + "prefs.sketch_open_look_at": "הפניית התצוגה אל הסקיצה בפתיחתה", + "prefs.sketch_open_look_at_note": "כבוי: יצירה או עריכה של סקיצה משאירות את התצוגה כפי שהיא. «מבט אל» מפנה את התצוגה אל הסקיצה כשרוצים.", "prefs.sketch_recent_snap": "הצמדה אוטומטית למרכזים ולנקודות אמצע אחרונים", "prefs.sketch_recent_snap_note": "בזמן שרטוט, הסמן נצמד למקום שבו נחתכים קווי העזר של שני מרכזי המעגלים או נקודות האמצע של קצוות ישרים האחרונים. כבוי: קווי העזר נשארים לתצוגה בלבד והסמן אינו זז.", "prefs.sketch_selection": "סקיצה ובחירה", @@ -2038,13 +2096,21 @@ "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": "כשתיבת דו־שיח או סקיצה פתוחות, סימן וי וסימן איקס בפינה הימנית העליונה של התצוגה עושים את מה שעושים אישור וביטול של תיבת הדו־שיח, או מסיימים ומבטלים את הסקיצה. ניווט התצוגה זז אל מתחתיהם.", "prefs.viewport": "אזור התצוגה", + "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.wheel": "גלגלת", "prefs.wheel_trackpad": "גלגלת ומשטח מגע", "prefs.workspace": "סביבת עבודה", @@ -2054,6 +2120,10 @@ "properties.title": "מאפיינים: {name}", "recovery.days_ago": "לפני {count} ימים", "recovery.detail": "{file} · {features} פיצ'רים · נשמר אוטומטית {when}", + "recovery.discard_all": "השלכת הכול", + "recovery.discard_all_detail": "לא ניתן לבטל פעולה זו.", + "recovery.discard_all_title": "להשליך {count} שמירות אוטומטיות?", + "recovery.discard_all_title_one": "להשליך שמירה אוטומטית אחת?", "recovery.hours_ago": "לפני {count} שע'", "recovery.just_now": "זה עתה", "recovery.min_ago": "לפני {count} דק'", @@ -2919,7 +2989,9 @@ "render.output_ready": "מוכן לרינדור", "render.output_run_check": "הפעל בדיקה", "render.output_size_limit": "{method}: עד {dimension} פיקסלים לצלע ו-{pixels} פיקסלים. הגודל הגדול ביותר שמתאים: {width} × {height}.", + "render.output_size_method_profile": "כדי לרנדר {width} × {height}, יש לבחור ב-{method} עם פרופיל משאבי CPU {profile} או בגודל קטן יותר.", "render.output_size_ok": "הגודל תקין · {width} × {height} פיקסלים", + "render.output_size_profile": "כדי לרנדר {width} × {height}, יש לבחור בפרופיל משאבי CPU {profile} או בגודל קטן יותר.", "render.output_swap_orientation": "החלפת רוחב וגובה", "render.output_tools_inspector": "ערוך גודל ופורמט במפקח הפלט.", "render.output_view_image": "הצג תמונה", @@ -3132,6 +3204,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": "תצוגה מקדימה", @@ -3419,6 +3492,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 חייב להיות מזהה פקודה", "shortcut.pin": "הצמדה לראש תיבת S", @@ -3455,6 +3546,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": "הטקסט עוקב אחר המסלול שלו: העקומה קובעת את מיקומו", @@ -3595,6 +3687,8 @@ "ui.3d_mouse_section": "עכבר תלת־ממדי", "ui.3d_mouse_show_in_view": "הצגת העכבר התלת־ממדי בתצוגה התלת־ממדית", "ui.3d_mouse_show_in_view_tip": "תמונה של העכבר התלת־ממדי בפינה השמאלית התחתונה, שזזה כשמזיזים אותו.", + "ui.3d_mouse_sketch_orbit": "סיבוב סקיצות בעכבר התלת־ממדי", + "ui.3d_mouse_sketch_orbit_tip": "העכבר התלת־ממדי מסובב את התצוגה גם כשסקיצה פתוחה ונראית חזיתית, כמו שהעכבר יכול. כבוי: הוא מזיז ומגדיל את הסקיצה עד שתטה את התצוגה.", "ui.3d_mouse_skip": "ההתקן שלי לא יכול לעשות את זה", "ui.3d_mouse_skip_tip": "מדלג על התנועה הזאת בשני הכיוונים, והכיול משמיט אותה.", "ui.3d_mouse_speed": "מהירות עכבר תלת־ממדי", @@ -3623,6 +3717,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "יש לסובב את הכיפה נגד כיוון השעון, להחזיק ואז לשחרר.", "ui.3d_mouse_switch_cap": "כיול כהתקן חזרה למרכז", "ui.3d_mouse_switch_trackball": "כיול ככדור עקיבה", + "ui.3d_mouse_use_direct": "שימוש במנהל ישיר", + "ui.3d_mouse_use_direct_tip": "קריאת העכבר התלת־ממדי ישירות (hidraw) במקום דרך spacenavd.", "ui.3d_mouse_view_axis": "ציר מבט", "ui.3d_mouse_view_off": "כבוי", "ui.3d_mouse_view_off_tip": "הפרופיל של מנהל ההתקן לעכבר התלת־ממדי מכבה את הציר הזה, ולכן התנועה שלו לא מזיזה את המבט. בחירה כאן בתנועה של ציר אחר מחליפה בין השניים, והתנועה ההיא מפסיקה להזיז את המבט.", @@ -3756,6 +3852,8 @@ "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": "שמירה", @@ -3939,6 +4037,7 @@ "ui.circle": "מעגל", "ui.circular_pattern": "מערך מעגלי", "ui.clear": "ניקוי", + "ui.clear_print_face": "נקה פאת הדפסה", "ui.clear_recent": "ניקוי האחרונים", "ui.clear_the_selection": "ניקוי הבחירה", "ui.click_selects_whole_bodies": "לחיצה בוחרת גופים שלמים", @@ -3973,6 +4072,15 @@ "ui.constant": "קבוע", "ui.constraints_conflict": "התנגשות בין אילוצים", "ui.construction": "עזר", + "ui.construction.angle_hint": "ישר עזר: בחירת נקודה ואז הקלדת זווית או בחירת כיוון", + "ui.construction.bisector_hint": "חוצה זווית: בחירת שני ישרים נחתכים ואז הפתרון הרצוי", + "ui.construction.common_hint": "משיק משותף: בחירת שני מעגלים/קשתות ואז הפתרון הרצוי", + "ui.construction.contact_hint": "משיק: בחירת מעגל/קשת או ספליין/חתך חרוט פתוח ואז נקודת השקה (לעקומות חופשיות: קצוות בלבד)", + "ui.construction.external_hint": "משיק: בחירת מעגל או קשת, נקודה חיצונית ואז הפתרון הרצוי", + "ui.construction.free_hint": "ישר עזר: לחיצה על שתי נקודות; לחיצה ימנית להתחלה מחדש", + "ui.construction.point_hint": "ישר עזר: לחיצה על נקודת המעבר", + "ui.construction.reference_hint": "ישר עזר: בחירת ישר ואז נקודה; לחיצה ימנית להחלפת הייחוס", + "ui.construction.ribbon_label": "קווי עזר", "ui.context_menu": "תפריט הקשר", "ui.continuity_g0": "G0 שבר", "ui.continuity_g1": "G1 משיק", @@ -4362,12 +4470,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": "יחידות הקובץ", @@ -4376,6 +4489,11 @@ "ui.polygon": "מצולע", "ui.preferences": "העדפות", "ui.press_a_key": "יש ללחוץ על מקש…", + "ui.print_face_cleared": "{body}: פאת ההדפסה נוקתה", + "ui.print_face_input": "פאת הדפסה", + "ui.print_face_one_body": "פאת הדפסה נבחרת לגוף אחד: שלח את הגוף הזה לבדו כדי לשנות אותה", + "ui.print_face_pick": "בחר פאה שטוחה אחת של גוף מוצק כדי להפוך אותה לפאת ההדפסה", + "ui.print_face_set": "{body}: פאת ההדפסה הוגדרה; שליחה לסלייסר מניחה את הגוף עליה", "ui.profile": "פרופיל", "ui.profile_plane": "מישור הפרופיל", "ui.profiles": "פרופילים", @@ -4439,6 +4557,7 @@ "ui.revolve": "סיבוב", "ui.ribbon_exit": "יציאה", "ui.ribbon_labels": "הצגת טקסט מתחת לסמלי רצועת הכלים", + "ui.ribbon_menus_skip_pinned": "השמטת פקודות מוצמדות מהתפריטים הנפתחים", "ui.ribbon_pin_footer": "פקודות מוצמדות מוצגות ברצועת הכלים. יש לרחף מעל שורה כדי להצמיד אותה או לבטל את הצמדתה.", "ui.ribbon_workspace": "סביבת עבודה", "ui.right": "ימין", @@ -4457,6 +4576,7 @@ "ui.selectOther": "בחירת אחר…", "ui.selectTangentChain": "בחירת שרשרת משיקה", "ui.select_all": "בחירת הכול", + "ui.select_components_by_face": "בחירת רכיבים (בחירת פאה בוחרת את הרכיב שלה)", "ui.selection": "בחירה", "ui.selection_modifiers": "מקשי שינוי לבחירה", "ui.selection_shift_add_only": "Shift+לחיצה מוסיף בלי להסיר", @@ -4498,6 +4618,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": "חתך", @@ -4602,10 +4727,19 @@ "ui.view_centre": "מרכז המבט", "ui.viewport_status_clear": "כיבוי {name}", "ui.viewport_status_clear_hint": "כיבוי {name} · Esc", + "ui.viewport_status_edges": "{count} קצוות", + "ui.viewport_status_from": "מכיוון {direction}", "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": "מוצלל עם מקצועות", @@ -4642,7 +4776,9 @@ "update.button": "עדכון", "update.button_version": "עדכון ל־{version}", "update.check_automatically": "בדיקת עדכונים אוטומטית", + "update.check_for_updates": "בדיקת עדכונים", "update.check_now": "בדיקה עכשיו", + "update.check_unavailable": "בדיקת עדכונים אינה זמינה עבור התקנה זו.", "update.checking": "בבדיקה…", "update.copy": "העתקה", "update.dmg_opened": "תמונת הדיסק פתוחה ואומתה: יש לגרור את SolidCraft לתיקיית היישומים במקום העותק הישן, ואז להפעיל מחדש את SolidCraft.", diff --git a/assets/locales/hr.json b/assets/locales/hr.json index fc3293f57c..d69697c65f 100644 --- a/assets/locales/hr.json +++ b/assets/locales/hr.json @@ -206,6 +206,7 @@ "Edges": "Bridovi", "Edit": "Uredi", "Edit Profile Sketch": "Uredi skicu profila", + "Edit shortcuts": "Uredi prečace", "Enable rule": "Omogući pravilo", "Enabled": "Omogućeno", "End Radius": "Krajnji polumjer", @@ -468,6 +469,7 @@ "Radius": "Polumjer", "Radius Fit": "Uklapanje polumjera", "Ratio": "Omjer", + "Read-only list: shortcuts can't be changed here.": "Popis samo za čitanje: prečaci se ovdje ne mogu mijenjati.", "Reattach selected face": "Ponovno pričvrsti odabrano lice", "Recover": "Oporavi", "Recover unsaved design?": "Oporaviti nespremljeni dizajn?", @@ -652,6 +654,7 @@ "Third point": "Treća točka", "This child is shared. Deleting it removes it from every copy of its parent.": "Ovaj podređeni element je dijeljen. Brisanjem se uklanja iz svake kopije njegova roditelja.", "This child is shared. Position changes apply to every copy of its parent.": "Ovaj podređeni element je dijeljen. Promjene položaja primjenjuju se na svaku kopiju njegova roditelja.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Ova se komponenta nalazi unutar podsklopa postavljenog više puta: njezino poravnavanje pomiče je u svakom postavljanju.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ovo lice ne može dati trajno pričvršćenje. Odaberite drugo lice ili izričito upotrijebite fiksnu ravninu.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ova slika još nije priložena na GitHubu. Kopirajte je ili spremite, a zatim je priložite na GitHubu.", "Thread depth": "Dubina navoja", @@ -772,6 +775,8 @@ "at the face": "na licu", "bom.group_children": "Podređeni dijelovi u retku svojeg dijela", "browser.body_name": "Tijelo{number}", + "browser.calibrate_click_a": "Kliknite sliku kako biste zadali točku A ili upišite njezine koordinate.", + "browser.calibrate_click_b": "Kliknite sliku kako biste zadali točku B ili upišite njezine koordinate.", "browser.calibrate_title": "Kalibriraj: {name}", "browser.capture_many": "Pomaknute komponente bez zabilježenog položaja: {count}.", "browser.capture_note": "Zabilježi zadržava novi položaj; Vrati ga vraća nazad.", @@ -793,6 +798,11 @@ "cam.roller": "Valjak", "cam.roller_pick": "Valjak (neobavezno)", "cam.tip_pick": "Vrh (neobavezno)", + "camera.shot_pitch": "Nagib", + "camera.shot_roll": "Valjanje", + "camera.shot_yaw": "Skretanje", + "canvas.decal_place_hint": "Kliknite lice kako biste postavili naljepnicu", + "canvas.place_hint": "Kliknite ravninu ili lice kako biste postavili platno", "choose a profile": "odaberite profil", "choose the cam's and the follower's joints": "odaberite spojeve brijega i sljedbenika", "choose the joints to couple": "odaberite spojeve koje treba povezati", @@ -800,12 +810,16 @@ "click sketch curves": "kliknite krivulje skice", "cmd.appearance_assign": "Izgled", "cmd.appearance_chain": "Lanac izgleda", + "cmd.appearance_copy": "Kopiraj izgled", + "cmd.appearance_favorite": "Omiljeni izgled", "cmd.appearance_library": "Biblioteka izgleda", "cmd.appearance_list": "Popis izgleda", "cmd.appearance_paint": "Oboji izgledom", + "cmd.appearance_paste": "Zalijepi izgled", "cmd.appearance_pick_up": "Preuzmi izgled", "cmd.appearance_replace": "Zamijeni izgled", "cmd.assembly_bom": "Sastavnica", + "cmd.body.print_face": "Ploha ispisa", "cmd.body_rename": "Preimenuj tijelo", "cmd.browser_collapse_all": "Sažmi sve", "cmd.browser_expand_all": "Proširi sve", @@ -813,6 +827,7 @@ "cmd.browser_move": "Premjesti stavke stabla modela", "cmd.browser_order": "Poredaj stavke stabla modela", "cmd.browser_rename_group": "Preimenuj grupu", + "cmd.browser_reparent": "Premjesti u komponentu", "cmd.browser_ungroup": "Razgrupiraj", "cmd.browser_visibility": "Prikaži/sakrij", "cmd.canvas_calibrate": "Kalibriraj platno", @@ -839,7 +854,9 @@ "cmd.component_metadata": "Svojstva dijela", "cmd.component_metadata_set": "Promijeni svojstva dijela", "cmd.component_move_bodies": "Premjesti tijela u komponentu", + "cmd.component_move_features": "Premjesti značajke u komponentu", "cmd.component_move_sketches": "Premjesti skice u komponentu", + "cmd.component_paste_from_design": "Zalijepi iz drugog dizajna", "cmd.component_paste_new": "Zalijepi kao novo", "cmd.component_properties": "Fizička svojstva komponente", "cmd.component_relink": "Ponovno poveži izvor", @@ -976,6 +993,7 @@ "cmd.motion_trace": "Trag kretanja", "cmd.motion_trace_edit": "Uredi trag kretanja", "cmd.motion_trace_keep": "Zadrži trag kretanja", + "cmd.occurrence_align": "Poravnaj primjerak", "cmd.occurrence_copy": "Zalijepi (primjerak)", "cmd.occurrence_drag": "Povuci primjerak", "cmd.occurrence_ground": "Učvrsti", @@ -1033,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Ukloni vezu površine", "cmd.render_library_targets": "Pronađi ciljeve biblioteke renderiranja", "cmd.render_lights_add": "Dodaj svjetlo", + "cmd.render_lights_duplicate": "Dupliciraj svjetlo renderiranja", + "cmd.render_lights_list": "Popis svjetala renderiranja", + "cmd.render_lights_mute": "Isključi svjetlo", + "cmd.render_lights_remove": "Ukloni svjetlo renderiranja", + "cmd.render_lights_set": "Postavi svjetlo renderiranja", "cmd.render_lights_solo": "Samo ovo svjetlo", "cmd.render_optics_bind": "Spremi optičku vezu", "cmd.render_optics_unbind": "Ukloni optičku vezu", "cmd.render_output_size": "Postavi veličinu izlaza renderiranja", "cmd.render_preflight": "Provjeri zahtjev za renderiranje", + "cmd.render_preflight_copy": "Kopiraj izvješće provjere renderiranja", "cmd.render_preset_apply": "Primijeni izvorni studio", "cmd.render_preset_resolve": "Pregledaj izvorni studio", "cmd.render_quality_prepare": "Pregledaj fiksni proračun uzorkovanja", @@ -1060,6 +1084,13 @@ "cmd.render_start": "Renderiraj sliku", "cmd.render_status": "Napredak renderiranja", "cmd.select_clear": "Poništi odabir", + "cmd.settings_profile_apply": "Primijeni profil postavki", + "cmd.settings_profile_delete": "Izbriši profil postavki", + "cmd.settings_profile_export": "Izvezi profil postavki", + "cmd.settings_profile_import": "Uvezi profil postavki", + "cmd.settings_profile_list": "Profili postavki", + "cmd.settings_profile_rename": "Preimenuj profil postavki", + "cmd.settings_profile_save": "Spremi profil postavki", "cmd.sheet_convert": "Pretvori u lim", "cmd.sheet_export_bend_table": "Izvezi tablicu savijanja", "cmd.sheet_export_dxf": "Izvezi razvijeni oblik kao DXF", @@ -1073,6 +1104,16 @@ "cmd.sheet_refold": "Ponovno presavij", "cmd.sheet_rules": "Popis pravila za lim", "cmd.sheet_unfold": "Razvij", + "cmd.sketch.construction_line": "Pomoćni pravac", + "cmd.sketch.construction_line.angle": "Pomoćni pravac pod kutom", + "cmd.sketch.construction_line.bisector": "Simetrala kuta", + "cmd.sketch.construction_line.horizontal": "Vodoravni pomoćni pravac", + "cmd.sketch.construction_line.parallel": "Paralelni pomoćni pravac", + "cmd.sketch.construction_line.perpendicular": "Pomoćni pravac okomit na pravac", + "cmd.sketch.construction_line.tangent_at": "Tangenta u točki krivulje", + "cmd.sketch.construction_line.tangent_external": "Tangenta kroz vanjsku točku", + "cmd.sketch.construction_line.tangent_two": "Zajednička tangenta", + "cmd.sketch.construction_line.vertical": "Okomiti pomoćni pravac", "cmd.sketch_arc3d": "3D luk", "cmd.sketch_arc_center_point": "Luk iz središta", "cmd.sketch_arc_tangent": "Tangentni luk", @@ -1105,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Veličina crteža", "cmd.sketch_edit": "Uredi skicu", "cmd.sketch_edit_text": "Uredi tekst", + "cmd.sketch_equation_curve": "Krivulja iz jednadžbe", + "cmd.sketch_equation_curve_edit": "Uredi krivulju iz jednadžbe", "cmd.sketch_export_dxf": "Spremi kao DXF", "cmd.sketch_finish_auto_constrain": "Završi s automatskim ograničenjima", "cmd.sketch_fit_curves_to_section": "Uklopi krivulje u presjek mreže", @@ -1201,6 +1244,7 @@ "cmd.view.render_browser": "Stablo renderiranja", "cmd.view.render_dock": "Premjesti panel renderiranja", "cmd.view.render_focus": "Fokusni prikaz renderiranja", + "cmd.view.status_items": "Statusne stavke prozora prikaza", "cmd.view_ambient_occlusion": "Ambijentalna okluzija", "cmd.view_angle_readout": "Kutovi prikaza", "cmd.view_apply": "Vrati imenovani prikaz", @@ -1222,6 +1266,7 @@ "cmd.view_normal_to": "Okomito na odabir", "cmd.view_previous_tab": "Prethodna kartica", "cmd.view_rename": "Preimenuj imenovani prikaz", + "cmd.view_render_frame": "Vodilice kadra renderiranja", "cmd.view_right": "Pogled zdesna", "cmd.view_save": "Novi imenovani prikaz", "cmd.view_shots_strip": "Traka kadrova", @@ -1232,10 +1277,14 @@ "color.copy": "Kliknite za kopiranje vrijednosti boje", "color.edit": "Kliknite za uređivanje boje", "color.gamma": "Prikaz vrijednosti boje u gama prostoru 0-255", + "color.hex": "Hex", + "color.hex_help": "Unesite #rrggbb ili #rrggbbaa; šest znamenki zadržava neprozirnost", + "color.hex_hint": "#rrggbb", "color.hue": "Nijansa", "color.linear": "Prikaz vrijednosti boje u linearnom prostoru 0-1", "color.saturation_value": "Zasićenost i vrijednost", "color.selected": "Odabrana boja", + "config.column_filter_empty": "Nema odgovarajućih stupaca", "contact.components_count": "Komponente: {count}", "contact.enabled_label": "omogućeno", "coupling.gear_1": "Zupčanik 1", @@ -1350,8 +1399,20 @@ "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.mit": "MIT licenca", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Izradite beskonačni vodoravni pomoćni pravac kroz točku. Njegovo ograničenje vodoravnosti ostaje na snazi pri kasnijim promjenama.", + "help.sketch.construction_line.parallel": "Odaberite ravnu liniju skice, pa postavite paralelni beskonačni pomoćni pravac. Desnim klikom odaberite drugu referencu.", + "help.sketch.construction_line.perpendicular": "Odaberite ravnu liniju skice, pa postavite beskonačni pomoćni pravac okomit na nju. Desnim klikom odaberite drugu referencu.", + "help.sketch.construction_line.tangent_at": "Izradite tangentu u točki na kružnici ili kružnom luku, ili u krajnjoj točki otvorenog splinea ili konike. Odaberite krivulju, pa točku dodira.", + "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.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.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.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.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.", @@ -1480,6 +1541,21 @@ "joint.one_component": "Ovi dijelovi su tijela jedne komponente: {bodies}. Spoj pomiče jednu komponentu u odnosu na drugu, pa svaki dio treba vlastitu komponentu.", "joint.rigid_group_one_component": "Sva tijela ({count}) nalaze se u jednoj komponenti (ili u samom dizajnu): najprije upotrijebite {command}.", "joint.rigid_group_select_components": "Odaberite dvije ili više komponenti.", + "joint_move.blocked_hint": "Provjerite vrstu spoja (npr. Kruti), učvršćenje, granice spoja i vrijednosti kojima upravljaju parametri.", + "joint_move.blocked_title": "Ovaj dio nije moguće pomaknuti", + "joint_move.coarse": "Korak rotacije blizu osi (°)", + "joint_move.distance": "Udaljenost od početka", + "joint_move.done": "Gotovo", + "joint_move.fine": "Korak rotacije daleko od osi (°)", + "joint_move.fixed": "Ovaj dio nema pomičnu os spoja.", + "joint_move.help": "Pomakni — povucite strelicu ili prsten. Esc poništava trenutno povlačenje.", + "joint_move.legacy_hint": "Isključite kako biste koristili prethodni panel Pomakni.", + "joint_move.precision": "Povucite izvan isprekidane vodilice za finu rotaciju.", + "joint_move.rotation": "Rotacija od početka", + "joint_move.select": "Odaberite lice ili jedan dio sklopa, a zatim odaberite Pomakni.", + "joint_move.settings": "Pomicanje u sklopu", + "joint_move.step": "Korak", + "joint_move.use_handles": "Koristi ručice kretanja spojeva za Pomakni u sklopu", "keymap.command_id": "ID naredbe", "keymap.empty": "Nema odgovarajućih naredbi", "keymap.use_anyway": "Ipak upotrijebi {key} ovdje", @@ -1574,6 +1650,8 @@ "nav.maya.help": "Alt+povlačenje lijevom tipkom orbitira, Alt+srednja tipka pomiče, Alt+desna tipka zumira; povlačenje srednjom tipkom također pomiče", "nav.openscad.help": "Povlačenje lijevom tipkom po praznom prostoru orbitira, Shift+povlačenje lijevom odabire okvirom, povlačenje geometrije skice uređuje je; povlačenje desnom ili srednjom tipkom pomiče, Shift+desna ili Shift+srednja zumira", "nav.perspective.sketch": "Uređivanje skice koristi ortografski prikaz. Završite skicu kako biste prešli na perspektivu.", + "nav.right_drag_orbits.help": "Isključeno: povlačenje desnom tipkom ne dira kameru, a desna tipka samo otvara kontekstni izbornik. Odabir mapiranja miša SolidWorks isključuje ovu opciju.", + "nav.right_drag_orbits.label": "Povlačenje desnom tipkom orbitira", "nav.solidworks.help": "Povlačenje srednjom tipkom orbitira, Ctrl+srednja pomiče, Shift+srednja zumira, povlačenje desnom orbitira", "nav.tinkercad.help": "Povlačenje desnom tipkom orbitira, povlačenje srednjom ili Shift+desnom pomiče", "nav.touchpad.help": "Držite Shift i pomičite pokazivač za pomicanje prikaza; držite Alt/Option i pomičite ga za orbitiranje. Tipka miša nije potrebna. Okomito pomicanje zumira.", @@ -1595,6 +1673,9 @@ "navbar.custom.customise": "Prilagodi navigacijsku traku…", "navbar.custom.design": "Dizajn", "navbar.custom.duplicate": "Ovo mjesto je već na traci.", + "navbar.custom.edge": "Rub", + "navbar.custom.edge_bottom": "Dolje", + "navbar.custom.edge_top": "Gore", "navbar.custom.fade": "Izblijedi", "navbar.custom.full": "Uklonite jedno mjesto kako biste dodali drugo.", "navbar.custom.hidden_design": "Skriveno u Dizajnu", @@ -1701,6 +1782,7 @@ "parts.dimension.thickness": "Debljina", "parts.dimension.width": "Širina", "parts.family.bearing": "Kuglični ležaj s dubokim utorom (ovojni oblik)", + "parts.family.button_head_cap_screw": "Vijak s poluokruglom glavom i unutarnjim šesterokutom", "parts.family.dowel_pin": "Cilindrični zatik", "parts.family.hex_bolt": "Vijak sa šesterokutnom glavom", "parts.family.hex_nut": "Šesterokutna matica", @@ -1759,6 +1841,11 @@ "prefs.bottom_centre": "Dolje u sredini", "prefs.bottom_right": "Dolje desno", "prefs.browser": "Stablo modela", + "prefs.browser_eye_left": "Oko pored strelice za sažimanje", + "prefs.browser_eye_left_note": "Redci stabla modela prikazuju oko za prikazivanje/skrivanje pored strelice za sažimanje, kao prije, umjesto u stupcu na desnom kraju redaka.", + "prefs.browser_reveal_on_hover": "Prikaži pri prelasku mišem", + "prefs.browser_show_note": "Prikazano drži stablo modela na zaslonu. Prikaži pri prelasku mišem skriva ga dok pokazivač ne dođe na njegovo mjesto ili na tanku karticu uz njegov lijevi rub, a ostaje otvoreno dok je pokazivač unutar njega ili dok je otvoren izbornik iz njega.", + "prefs.browser_shown": "Prikazano", "prefs.built_in": "Ugrađeno", "prefs.button_left": "Povlačenje lijevom tipkom", "prefs.button_middle": "Povlačenje srednjom tipkom", @@ -1806,6 +1893,8 @@ "prefs.graphics_card_restart": "Stupa na snagu nakon ponovnog pokretanja SolidCrafta. WGPU_ADAPTER_NAME ima prednost nad ovim odabirom.", "prefs.graphics_note": "Žrtvujte brzinu za glatkoću. Svaki odabir pokazuje svoj učinak na primjeru.", "prefs.graphics_presets_note": "Unaprijed zadane vrijednosti postavljaju sve odabire ispod odjednom.", + "prefs.hide_constraint_glyphs_zoomed_out": "Sakrij simbole ograničenja kada je prikaz umanjen", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Simboli ograničenja nestaju iz prikaza kada je skica toliko umanjena da bi je zatrpali. Isključeno: ostaju fiksne veličine na zaslonu pri svakom zumiranju.", "prefs.history": "Vremenska traka", "prefs.history_origin_bodies": "Prikaži ishodište i tijela na vrhu", "prefs.history_origin_bodies_note": "Vremenska traka lijevo ili ispod stabla modela počinje mapom Tijela aktivne komponente i Ishodištem, s njihovim okom i izbornicima kao u stablu modela. Vremenska traka na dnu ostaje nepromijenjena.", @@ -1829,6 +1918,8 @@ "prefs.layout_note": "Kompaktno pričvršćuje panele uz rubove prozora. Prozirnost stabla modela dostupna je u opciji Plutajuće. Stranu Asistenta promijenite na stranici Asistent.", "prefs.left": "Lijevo", "prefs.left_click": "Lijevi klik", + "prefs.line_drag_draws_one": "Pritisak i povlačenje crta jednu liniju", + "prefs.line_drag_draws_one_note": "U alatu Linija pritisak, povlačenje i otpuštanje crta jednu liniju od mjesta pritiska do mjesta otpuštanja i ondje završava. Klikovi i dalje crtaju lanac. Isključeno: povlačenje postavlja točku ondje gdje se otpusti, kao i klik.", "prefs.linked_designs": "Povezani dizajni", "prefs.live_input": "Ulaz uživo", "prefs.live_preview": "Pregled uživo", @@ -1842,6 +1933,9 @@ "prefs.max_file_size_step": "STEP i uvezene datoteke", "prefs.max_file_size_web": "Web aplikacija otvara STEP i uvezene datoteke do 512 MB te dizajne do 256 MB: kartica preglednika ne može primiti veće datoteke. Desktop aplikacija može podići ove granice.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Velike ikone", + "prefs.menu_list": "Popis", + "prefs.menus": "Izbornici", "prefs.minutes": "minuta", "prefs.minutes_suffix": " min", "prefs.mouse": "Miš i dodirna pločica", @@ -1861,14 +1955,40 @@ "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.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", "prefs.placement": "Postavljanje", "prefs.prefer_free_sketch_points": "Daj prednost pomičnim točkama skice kad se točke preklapaju", "prefs.preview_choices": "Pregled vaših odabira", + "prefs.print_face_highlight": "Isticanje plohe ispisa", + "prefs.print_face_highlight_tip": "Koliko se jako ploha ispisa tijela boji i obrubljuje u prikazu. 0 % ne prikazuje isticanje.", + "prefs.profile_active": "Aktivni profil", + "prefs.profile_applied": "Profil je primijenjen.", + "prefs.profile_changed": "Postavke su se promijenile otkako je profil spremljen. Spremi ih zadržava u njemu.", + "prefs.profile_deleted": "Profil je izbrisan.", + "prefs.profile_export": "Izvezi…", + "prefs.profile_exported": "Profil je izvezen.", + "prefs.profile_imported": "Profil je uvezen. Odaberite ga pod Aktivni profil kako biste se prebacili na njega.", + "prefs.profile_name_hint": "Naziv profila, npr. Prijenosnik", + "prefs.profile_rename": "Preimenuj", + "prefs.profile_renamed": "Profil je preimenovan.", + "prefs.profile_save": "Spremi", + "prefs.profile_saved": "Trenutne postavke spremljene su u profil.", + "prefs.profile_skipped": "Profil je primijenjen; postavke koje su zadržale trenutnu vrijednost: {n} (pogledajte zapisnik).", + "prefs.profiles": "Profili postavki", + "prefs.profiles_note": "Prečaci, alatna traka, navigacija, radijalni izbornik i postavke sučelja, spremljeni zajedno pod jednim nazivom i mijenjani odjednom.", + "prefs.project_options_dialog": "Dijalog opcija projiciranja", + "prefs.project_options_dialog_note": "Uključeno: prije projiciranja odaberite Geometrija ili Tijela i Veza projekcije. Isključeno: zadržava se prethodni način rada s izravnim odabirom.", "prefs.quality_default": "zadano", "prefs.quality_fastest": "najbrže", "prefs.quality_slowest": "najsporije", "prefs.quality_smoother": "glađe", + "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}", + "prefs.ribbon_reveal": "Prikaži rezultate pretraživanja na vrpci", + "prefs.ribbon_reveal_note": "Zadržavanje pokazivača iznad rezultata u Pretraži naredbe pokazuje gdje se naredba nalazi na vrpci: njezinu karticu, gumb ili redak u padajućem izborniku panela. Naredba pokrenuta iz pretraživanja ondje zatreperi, a pokrenuta naredba iz padajućeg izbornika označava svoj panel.", "prefs.right_click": "Desni klik", "prefs.saving": "Automatsko spremanje i oporavak", "prefs.scale_note": "Također Ctrl + / Ctrl − u prozoru. Gumbi su 28 px pri 100 %.", @@ -1877,18 +1997,29 @@ "prefs.search_empty": "Nema odgovarajućih postavki", "prefs.select": "Odabir", "prefs.select_one_item": "Odaberi jednu stavku", + "prefs.select_other_hidden": "Odaberi skrivenu geometriju pomoću Odaberi drugo", + "prefs.select_other_hidden_help": "Uključeno: obuhvaća skrivena tijela, skice i zaklonjene bridove u blizini pokazivača. Isključeno: koristi se izvorni popis vidljive geometrije ispod pokazivača.", "prefs.selecting": "Odabiranje", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift samo dodaje; Ctrl / Cmd uključuje i isključuje", "prefs.shift_add_note": "Uključeno: Shift dodaje, Ctrl / Cmd uključuje i isključuje. Isključeno (zadano): oba uključuju i isključuju.", "prefs.shortcuts": "Prečaci", + "prefs.single_surface_analysis": "Koristi samo jednu analizu ploha", + "prefs.single_surface_analysis_help": "Kad je isključeno, sjenčanje ploha i Kontinuitet mogu ostati uključeni zajedno. Kad je uključeno, zadržava se posljednja pokrenuta ili promijenjena analiza.", + "prefs.sketch_align_points": "Poravnaj s drugim točkama skice tijekom crtanja", + "prefs.sketch_align_points_note": "Tijekom crtanja točka se istodobno poravnava vodoravno s jednom točkom skice i okomito s drugom, uz točkastu vodilicu, a klik zadržava oba poravnanja. Isključeno: samo s prethodnom točkom, po jedna os.", "prefs.sketch_conflict_colour": "Geometrija u sukobu u boji sukoba", "prefs.sketch_conflict_colour_note": "Dok su ograničenja skice u sukobu, krivulje i točke koje drži neuspjelo ograničenje crtaju se i u boji sukoba, a ne samo njegov simbol ili kota. Isključeno: zadržavaju uobičajene boje.", + "prefs.sketch_construction_lines": "Prošireni alati za pomoćne pravce", "prefs.sketch_exit_view": "Kad se skica završi", "prefs.sketch_exit_view.keep": "Zadrži trenutni prikaz", "prefs.sketch_exit_view.previous": "Vrati se na prethodni prikaz", "prefs.sketch_exit_view.unless_rotated": "Vrati se osim ako je rotirano", "prefs.sketch_exit_view_note": "Kamo prikaz ide nakon Završi skicu, Odustani ili dvostrukog Esc.", + "prefs.sketch_open_look_at": "Okreni kameru prema skici pri otvaranju", + "prefs.sketch_open_look_at_note": "Isključeno: kad izradite ili uredite skicu, prikaz ostaje onakav kakav jest. Pogledaj okreće prikaz prema skici kad to želite.", + "prefs.sketch_recent_snap": "Automatski privlači na nedavna središta i središnje točke", + "prefs.sketch_recent_snap_note": "Tijekom crtanja pokazivač se privlači ondje gdje se sijeku vodilice dvaju posljednjih središta kružnica ili središnjih točaka ravnih bridova. Isključeno: te vodilice služe samo za prikaz i pokazivač se ne pomiče.", "prefs.sketch_selection": "Skica i odabir", "prefs.sketch_selection_heading": "Skica i odabir", "prefs.sketch_selection_note": "Kako se skice privlače i kako klikovi odabiru.", @@ -1933,7 +2064,14 @@ "prefs.toast_place": "Obavijesti", "prefs.toggle_item": "Dodaj stavku ili je ponovno ukloni", "prefs.toolbar": "Alatna traka", + "prefs.toolbar_tabs": "Kartice alatne trake", "prefs.top": "Gore", + "prefs.trackpad_zoom_modifier": "Zumiranje pomicanjem s dva prsta uz držanje tipke", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Držite samo ovu tipku kako biste okomitim pomicanjem zumirali prema pokazivaču. Isključeno zadržava postojeće ponašanje pomicanja.", + "prefs.trackpad_zoom_modifier.off": "Isključeno", "prefs.translucent": "Prozirno", "prefs.under_browser": "Ispod stabla modela", "prefs.under_cursor": "Ispod pokazivača", @@ -1943,13 +2081,24 @@ "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.", "prefs.viewport": "Prozor prikaza", + "prefs.viewport_analysis_status": "Prikaži status analize u prozoru prikaza", + "prefs.viewport_analysis_status_help": "Prikazuje aktivne analize iznad prozora prikaza, otvara njihove legende iz svake stavke, a tipka Esc isključuje najnoviju analizu. Isključite kako biste vratili legende u kutovima i prethodno ponašanje tipke Esc.", + "prefs.viewport_background": "Pozadina", + "prefs.viewport_background_colour": "Boja", + "prefs.viewport_background_theme": "Gradijent teme", "prefs.wheel": "Kotačić", "prefs.wheel_trackpad": "Kotačić i dodirna pločica", "prefs.workspace": "Radni prostor", + "prefs.zoom_stop_at_model": "Zaustavi zumiranje na modelu", + "prefs.zoom_stop_at_model_tip": "3D miš i aplikacija uređaja zumiraju prema površini u sredini prikaza; zumiranje povlačenjem zadržava točku na kojoj je povlačenje počelo. Isključeno: zumiranje može proći kroz model. Kotačić i štipanje zasebno koriste Zumiranje prema pokazivaču", "prefs.zoom_toward_cursor": "Zumiranje prema pokazivaču", "properties.title": "Svojstva: {name}", "recovery.days_ago": "prije {count} dana", "recovery.detail": "{file} · značajki: {features} · automatski spremljeno {when}", + "recovery.discard_all": "Odbaci sve", + "recovery.discard_all_detail": "Ovo se ne može poništiti.", + "recovery.discard_all_title": "Odbaciti automatska spremanja ({count})?", + "recovery.discard_all_title_one": "Odbaciti 1 automatsko spremanje?", "recovery.hours_ago": "prije {count} h", "recovery.just_now": "upravo", "recovery.min_ago": "prije {count} min", @@ -2034,6 +2183,7 @@ "render.appearance_editor.weight": "Težina", "render.appearance_physics_separate": "Vizualni izgled ne dodjeljuje gustoću, toplinske podatke niti druga inženjerska svojstva.", "render.appearances": "Izgledi", + "render.assign_help": "Kliknite za primjenu na odabir; povucite za pregled i ispustite ili, kad ništa nije odabrano, kliknite za bojenje.", "render.availability_native": "Izvorni sastav; pregledajte prije primjene", "render.availability_projection": "Podržana projekcija; pogledajte opseg i izostavke", "render.availability_reference": "Referentni podaci; nije automatska dodjela", @@ -2072,6 +2222,7 @@ "render.bloom_threshold": "Prag linearnih svijetlih dijelova", "render.browse_capabilities": "Pregledaj potpuni katalog mogućnosti", "render.browse_environments": "Pregledaj izvorna HDR okruženja", + "render.browse_looks": "Pregledaj…", "render.browse_native_scenes": "Pregledaj izvorne studije", "render.browse_physical": "Pregledaj fizičke reference", "render.browse_quality": "Pregledaj specifikacije kvalitete", @@ -2095,6 +2246,82 @@ "render.check_opaque": "Prozirnost: samo neprozirne površine; alfa pozadine je podržana.", "render.check_sorted_alpha": "Prozirnost: sortirana aproksimacija alfe.", "render.check_transmission": "Prozirnost: fizičko propuštanje svjetlosti.", + "render.children.absolute_floor": "Apsolutni donji prag", + "render.children.adaptive": "Prilagodljivo", + "render.children.add_look_reason": "Dodajte izgled kako biste uredili njegovo uključeno stanje i jačinu.", + "render.children.all_shots": "Svi kadrovi", + "render.children.bloom": "Sjaj", + "render.children.check": "Provjera", + "render.children.check_now": "Provjeri sada", + "render.children.chromatic": "Kromatski pomak", + "render.children.compact": "Kompaktno", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopiraj", + "render.children.copy_job": "Kopiraj JSON posla", + "render.children.copy_request": "Kopiraj JSON zahtjeva", + "render.children.coverage_error": "Pogreška pokrivenosti", + "render.children.cpu": "CPU praćenje zraka", + "render.children.denoise": "Smanji šum na dovršenoj slici", + "render.children.depth": "Dubina putanje", + "render.children.distortion": "Izobličenje", + "render.children.edge_samples": "Uzorci rubova", + "render.children.effects_banner": "Ovi efekti zahtijevaju GPU praćenje putanja. Otvaranje ovoga nikad ne mijenja metodu.", + "render.children.enable_adaptive": "Omogućite neovisne probne uzorke kako biste uredili prilagodljiva ograničenja.", + "render.children.enable_bloom": "Omogućite sjaj kako biste uredili njegovu jačinu, prag i polumjer.", + "render.children.finishing": "Završna obrada", + "render.children.flare": "Odsjaj", + "render.children.fresnel": "Fresnelovi slojevi", + "render.children.ghosts": "Duhovi prstenova", + "render.children.glass": "Prijenos kroz staklo", + "render.children.go_to": "Idi na {editor}", + "render.children.grain": "Zrno", + "render.children.halo": "Oreol", + "render.children.hdr": "HDR", + "render.children.keep_master": "Zadrži sirovi linearni master", + "render.children.last_result": "Posljednje: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efekti objektiva", + "render.children.look_summary": "{tone} · izgledi: {count}", + "render.children.looks": "Kreativni izgledi", + "render.children.memory": "Memorija", + "render.children.method": "Metoda", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Zahtijeva CPU praćenje zraka. Otvaranje ovoga nikad ne mijenja metodu.", + "render.children.nested": "Potvrđeni ugniježđeni dielektrici", + "render.children.not_checked": "Nije provjereno", + "render.children.path": "Praćenje putanja", + "render.children.pilots": "Probni uzorci", + "render.children.preview": "Podaci pregleda", + "render.children.progressive_reason": "Odaberite CPU progresivno kako biste uredili broj uzoraka i sjeme.", + "render.children.radius": "Polumjer", + "render.children.reflect_depth": "Dubina refleksije", + "render.children.reflect_samples": "Uzorci refleksije", + "render.children.regrade": "Ponovna gradacija", + "render.children.relative_error": "Relativna pogreška", + "render.children.render": "Renderiranje", + "render.children.render_shot": "Renderiraj kadar", + "render.children.renderer": "Iscrtavač", + "render.children.renderers": "Iscrtavači", + "render.children.request": "Ovaj zahtjev", + "render.children.reset_finishing": "Vrati zadanu završnu obradu", + "render.children.review_quality": "Pregledaj specifikaciju kvalitete…", + "render.children.rgb_gain": "RGB pojačanje", + "render.children.samples": "Uzorci", + "render.children.sampling": "Uzorkovanje", + "render.children.saturation": "Zasićenost", + "render.children.scene_summary": "Tijela: {bodies} · trokuti: {triangles}", + "render.children.seed": "Sjeme", + "render.children.sharpening": "Izoštravanje", + "render.children.single_image": "Jedna slika", + "render.children.soft_focus": "Meki fokus", + "render.children.streak": "Pruga", + "render.children.strength": "Jačina", + "render.children.studio": "Studio", + "render.children.threshold": "Prag", + "render.children.tone": "Ton", + "render.children.under_hood": "Ispod haube", + "render.children.vignette": "Vinjeta", + "render.children.workstation": "Radna stanica", + "render.choose_look": "Najprije odaberite izgled u Izgledima", "render.chromatic_offset": "Kromatski pomak rubova", "render.clear_stage": "Ukloni spremljenu scenu", "render.copy_request": "Kopiraj JSON", @@ -2346,8 +2573,33 @@ "render.flare_strength": "Jačina odsjaja svijetlih dijelova", "render.flare_threshold": "Linearni prag odsjaja scene", "render.fold_browser": "Sažmi stablo modela", + "render.frame_aspect": "Omjer", + "render.frame_centre_mark": "Oznaka središta", + "render.frame_copy_limit": "Nema dostupnog naziva za duplikat kadra", + "render.frame_copy_name": "{name} kopija {number}", + "render.frame_custom": "Prilagođeno", + "render.frame_distance_unit": " × veličina", + "render.frame_duplicate": "Dupliciraj", + "render.frame_exposure": "Ekspozicija", + "render.frame_fov": "Vidno polje", + "render.frame_guides": "Vodilice", + "render.frame_invalid_aspect": "Neispravan unaprijed zadani omjer", + "render.frame_lens": "Vidno polje {fov}° · ≈ {mm} mm", + "render.frame_name": "Naziv", + "render.frame_output_size": "Veličina izlaza", + "render.frame_photo": "3:2 · Fotografija", + "render.frame_portrait": "4:5 · Portret", "render.frame_presets": "Unaprijed zadani kadrovi", "render.frame_presets_pending": "Stiže s uređivačem kadrova", + "render.frame_projection": "Projekcija", + "render.frame_render_shot": "Renderiraj ovaj kadar", + "render.frame_rule_thirds": "Pravilo trećina", + "render.frame_safe_area": "Sigurno područje", + "render.frame_shot_title": "Kadar · {name}", + "render.frame_square": "1:1 · Kvadrat", + "render.frame_tall": "9:16 · Okomito", + "render.frame_thirds": "Trećine", + "render.frame_wide": "16:9 · Široki zaslon", "render.gpu_depth": "Maksimalna dubina putanje", "render.gpu_options_help": "Uzorci, dubina, sjeme i ekspozicija spremaju se s ovom postavkom. Sprema se samo aktivna metoda slike.", "render.gpu_path_failed": "GPU praćenje putanja nije dostupno", @@ -2375,6 +2627,7 @@ "render.heading_results": "REZULTATI", "render.heading_shots": "KADROVI", "render.hide_shots_label": "Sakrij traku kadrova", + "render.hover_look": "{object} · {look} · kliknite za odabir", "render.image_advanced": "Napredne GPU kontrole", "render.image_cancelled": "Renderiranje je prekinuto", "render.image_cancelling": "Prekidanje", @@ -2521,17 +2774,95 @@ "render.library_uniform_ack": "Upotrijebi jednoličnu aproksimaciju bez prostornih ili naprednih efekata", "render.library_usable": "Izvršne projekcije", "render.library_users": "Korisnici", + "render.light_aim": "Usmjeri prema", "render.light_anchor_pending": "Promjena koordinatnog sustava svjetla još nije podržana.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Odaberite CPU praćenje zraka ili izričiti GPU studio kako biste uređivali svjetla. Svjetla starog GPU studija ostaju samo za čitanje.", + "render.light_behaviour": "PONAŠANJE", + "render.light_default_fill": "Popunjavajuće", + "render.light_default_key": "Glavno", + "render.light_default_number": "Svjetlo {number}", + "render.light_default_rim": "Rubno", + "render.light_delete": "Izbriši", + "render.light_distance": "Udaljenost", + "render.light_duplicate": "Dupliciraj", + "render.light_elevation": "Elevacija", + "render.light_gpu_punctual": "Točkasta svjetla i reflektori dostupni su uz CPU praćenje zraka.", + "render.light_hard_shadow": "Ovo svjetlo ima oštre sjene; mekoća emitera nije dostupna.", + "render.light_intensity": "Intenzitet", + "render.light_missing": "Ovo svjetlo više ne postoji. Odaberite svjetlo u stablu modela.", + "render.light_place_highlight": "Postavi prema odsjaju", + "render.light_place_pending": "Postavljanje prema odsjaju zahtijeva odabir svjetla u prozoru prikaza i još nije podržano.", + "render.light_position": "POLOŽAJ", + "render.light_preview_help": "Prikaz uživo je bez sjena; površinska svjetla koriste aproksimaciju s uzorkom u središtu. Renderiranje koristi potpuni model svjetla.", + "render.light_product_centre": "Središte proizvoda", + "render.light_size": "Veličina", + "render.light_softness": "Mekoća", + "render.light_temperature": "Temperatura boje", + "render.light_temperature_help": "Približna nijansa crnog tijela; sprema se kao linearni RGB.", + "render.light_type": "Vrsta", + "render.light_type_area": "Površinsko", + "render.light_type_point": "Točkasto", + "render.light_type_spot": "Reflektor", + "render.light_type_sun": "Sunce", + "render.light_view_only": "Samo ovo svjetlo i isključivanje svjetla utječu na ovaj prikaz i njegova renderiranja; nikad se ne spremaju u dizajn.", + "render.light_world_origin": "Ishodište svijeta", + "render.lighting_aim_point": "Točka usmjeravanja", + "render.lighting_apply": "Primijeni postavku", + "render.lighting_area_samples": "Uzorci površinskog svjetla", + "render.lighting_budget": "Proračun", + "render.lighting_budget_help": "Brojevi su samo za čitanje. Dodajte, duplicirajte ili izbrišite svjetla kako biste ih promijenili; metoda određuje ograničenja.", + "render.lighting_colour": "Boja", + "render.lighting_consent": "Zamijeni usmjerena svjetla ovom postavkom", + "render.lighting_custom": "Prilagođena svjetla", + "render.lighting_custom_help": "Odabirom opcije Prilagođeno postavka se pretvara u svjetla koja se mogu uređivati, svako u svom retku u stablu.", + "render.lighting_direction": "Smjer X Y Z", + "render.lighting_direction_help": "Ili upišite smjer prema svjetlu (X Y Z).", + "render.lighting_distance_unit": " × veličina", + "render.lighting_edge_u": "Vektor stranice U", + "render.lighting_edge_v": "Vektor stranice V", + "render.lighting_falloff": "Slabljenje", + "render.lighting_falloff_help": "Intenzitet je naveden za referentnu udaljenost.", + "render.lighting_height": "Visina V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Isključi ovo svjetlo", + "render.lighting_point": "Točka", + "render.lighting_preset": "Postavka studija", + "render.lighting_preset_cpu": "Usmjerene postavke studija zahtijevaju CPU praćenje zraka. GPU osvjetljenje koristi izričita svjetla vrste Sunce i Površinsko.", + "render.lighting_preset_row": "Postavka", + "render.lighting_radiance": "Radijancija", + "render.lighting_reference": "Referenca", + "render.lighting_review_help": "Zamjenjuje popis usmjerenih svjetala. Zadržava točkasta i površinska svjetla, okruženje, sjene, kameru i sve ostale postavke kadra.", + "render.lighting_review_title": "Pregledaj postavku osvjetljenja", + "render.lighting_samples_cpu": "Uzorci površinskog svjetla primjenjuju se na CPU praćenje zraka.", + "render.lighting_samples_need_area": "Dodajte CPU površinsko svjetlo kako biste postavili uzorke površinskog svjetla.", + "render.lighting_samples_reason": "Uzorci površinskog svjetla moraju biti 1, 4, 16 ili 64.", + "render.lighting_shadow_cpu": "Prekidač sjena zahtijeva CPU praćenje zraka. GPU praćenje putanja uvijek baca sjene.", + "render.lighting_shadows": "Baci sjene", + "render.lighting_shadows_section": "Sjene i uzorkovanje", + "render.lighting_shape": "Oblik", + "render.lighting_solo": "Samo ovo svjetlo", + "render.lighting_source": "Izvor", + "render.lighting_source_section": "Izvor svjetla", + "render.lighting_spot_preserved": "Ovo točkasto svjetlo zadržava spremljeni stožac reflektora dok mu se ne promijeni vrsta.", + "render.lighting_sun_distance": "Smjer sunca ne ovisi o udaljenosti i točki usmjeravanja.", + "render.lighting_unsupported": "nepodržano", + "render.lighting_used": "{used} od {limit}", + "render.lighting_width": "Širina U", + "render.lighting_world_position": "Svjetski X Y Z", "render.lights_fixed_to": "Svjetla vezana za", "render.linear_help": "Zadržava 32-bitni float RGBA master prije uklanjanja šuma, ekspozicije, mapiranja tonova, filtara i pozadine. Linearni sRGB/D65; pomnožen pokrivenošću. Dodatna memorija izričito se odobrava. Bez sažimanja i dodatnih prolaza.", "render.linear_retain": "Zadrži sirovi linearni EXR", "render.linear_unavailable": "Odabrani dovršeni kadar nema zadržani EXR. Omogućite zadržavanje prije renderiranja; stariji PNG ne može vratiti sirovu radijanciju.", "render.look_add": "Dodaj izgled boje", + "render.look_applied": "Primijenjeno: {name}", "render.look_bleach": "Po uzoru na bleach bypass", "render.look_cool": "Hladno", "render.look_cyanotype": "Po uzoru na cijanotipiju", "render.look_faded": "Izblijedjeli otisak", + "render.look_material": "Materijal", "render.look_mono": "Jednobojno", + "render.look_pasted": "Izgled je zalijepljen", "render.look_sepia": "Sepija", "render.look_strength": "Jačina izgleda", "render.look_teal_orange": "Tirkizno / narančasto", @@ -2607,9 +2938,11 @@ "render.output_advanced": "Napredno", "render.output_backend_ready": "Spremno", "render.output_check": "Provjeri", + "render.output_check_failed": "Provjera · pogreška", "render.output_check_help": "Pokrenite Provjeri da pregledate ovaj zahtjev bez pokretanja renderiranja.", "render.output_check_summary": "Provjera · Napomene: {count}", "render.output_copy": "Kopiraj", + "render.output_copy_report": "Kopiraj izvješće", "render.output_destination": "Odredište", "render.output_destination_help": "Odaberite odredišnu datoteku pri izvozu dovršene slike.", "render.output_dimensions": "Veličina", @@ -2619,6 +2952,7 @@ "render.output_fit": "Uklopi", "render.output_format": "Format", "render.output_format_status": "PNG je točna dovršena slika za prikaz. Neobavezni GPU EXR zadržava sirovu linearnu radijanciju scene u formatu FLOAT32 prije filtara prikaza. Izvoz nikad ne zamjenjuje predmemoriju pregleda ni drugi posao.", + "render.output_grading": "Gradacija", "render.output_image": "Slika", "render.output_largest": "najviše {width} × {height}", "render.output_lock_ratio": "Zaključaj omjer stranica", @@ -2628,6 +2962,8 @@ "render.output_quality_waiting": "čeka definiciju kvalitete", "render.output_ratio_refused": "Povezana dimenzija premašuje ograničenje veličine ove metode. Prethodna veličina zadržava se; otključajte omjer ili odaberite manju vrijednost.", "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_ok": "Veličina u redu · {width} × {height} px", "render.output_swap_orientation": "Zamijeni širinu i visinu", "render.output_tools_inspector": "Veličinu i format uredite u inspektoru Izlaz.", @@ -2644,6 +2980,7 @@ "render.page_output": "Izlaz", "render.page_scene": "Scena", "render.paint_label": "Oboji", + "render.painting_with": "Bojenje izgledom: {name}", "render.passive_stage": "Podloga i pasivna scena", "render.passive_stage_help": "Konačni pasivni paneli pripadaju samo renderiranim slikama. Ne dodaju CAD tijela ni masu. Postavljanje je fiksno nakon primjene; presijecanje granica proizvoda odbija se. CPU ne daje difuzno odbijeno osvjetljenje.", "render.perspective": "Perspektivna kamera", @@ -2665,6 +3002,7 @@ "render.pick_help": "Odaberite Tijelo, Lice ili Komponenta. Kliknite prazan prostor kako biste očistili odabir; Shift ili Ctrl dodaje ili uključuje/isključuje. Isticanja se pojavljuju samo u živom prikazu proizvoda.", "render.pick_label": "Odaberi", "render.pick_up_label": "Preuzmi", + "render.pick_up_prompt": "Kliknite objekt kako biste preuzeli njegov izgled", "render.pick_waiting": "Odabir proizvoda još nije podržan.", "render.post_contrast": "Kontrast", "render.post_gain_b": "Pojačanje plave", @@ -2704,13 +3042,17 @@ "render.radial_distortion": "Radijalno izobličenje", "render.radial_fit": "Uklopi proizvod", "render.radial_save": "Spremi kadar", + "render.recent_looks": "Nedavni izgledi", "render.regrade_action": "Ponovna gradacija sirovog mastera (bez uklanjanja šuma)", "render.regrade_help": "Izrađuje zaseban PNG iz odabranog dovršenog sirovog mastera, koristeći ove kontrole prikaza. Ne prati zrake, ne mijenja zabilježenu veličinu, kameru ni svjetla i ne prebrisuje EXR. Uklanjanje šuma ne primjenjuje se ponovno: sirovi masteri ne čuvaju njegove vodilje. Izvedena slika i dalje se može izvesti istim kontrolama posla.", "render.regrade_label": "Ponovna gradacija", "render.reload_settings": "Ponovno učitaj spremljene postavke", + "render.remove_override": "Ukloni nadjačavanje", + "render.replace_all": "Zamijeni sve", "render.replace_ground": "Potvrdi zamjenu postojeće scene", "render.replace_label": "Zamijeni", "render.replace_saved_surface": "Zamijeni postojeću spremljenu vezu za ovaj cilj", + "render.replace_with": "Zamijeni s", "render.restore_camera": "Vrati spremljenu kameru", "render.result_actual": "100%", "render.result_actual_help": "Jedan piksel slike po pikselu zaslona.", @@ -2762,14 +3104,28 @@ "render.saved_surface_remove": "Ukloni spremljenu vezu", "render.saved_surfaces": "Spremljene veze površina", "render.saved_surfaces_help": "Fiksirane ugrađene mape prate odabrani primjerak u lokalnim milimetrima. Zamijenjen ili nedostajući cilj pogreška je, nikad automatska ponovna dodjela.", + "render.scene_all_shots": "Svi kadrovi ({count})", "render.scene_apply": "Primijeni pregledani studio", + "render.scene_batch_reason": "Opcija Svi kadrovi zahtijeva red čekanja za renderiranje spremljenih kadrova, koji još nije dostupan.", + "render.scene_check_now": "Provjeri sada", + "render.scene_check_passed": "Provjera uspješna · napomene: {notes}", + "render.scene_check_stale": "Provjera je zastarjela · provjerite ponovno", + "render.scene_check_unchecked": "Još nije provjereno · 0 napomena", "render.scene_consent": "Potvrdi zamjenu osvjetljenja, okruženja, ekspozicije i scene", + "render.scene_light_summary": "{environment} · svjetla: {lights}", + "render.scene_look_summary": "Izgledi: {looks} · nadjačavanja: {overrides}", + "render.scene_open_check": "Otvori provjeru", "render.scene_preserved_fields": "Kamera, uzorkovanje, dimenzije, optičke veze, CAD geometrija i inženjerski materijali čuvaju se.", "render.scene_preserves_design": "Sastavite osvjetljenje i pasivnu scenu za vidljivi predmet. Geometrija, inženjerski materijali i kamera ostaju odvojeni.", + "render.scene_ready": "Spremno za renderiranje", + "render.scene_reminder": "Scena čuva dizajn. Geometrija, inženjerska svojstva i CAD nikad se ne mijenjaju iz Renderiranja.", + "render.scene_reminders": "Podsjetnici", + "render.scene_renderer_ready": "{renderer}: spremno", "render.scene_replaces": "Zamjenjuje", "render.scene_review": "Razriješi i pregledaj", "render.scene_review_help": "Pregledajte potpunu izvornu kompoziciju prije primjene. Nevezane napredne SCENE specifikacije ne zamjenjuju se.", "render.scene_scope_details": "Opseg kompozicije", + "render.scene_stages": "Faze", "render.search_appearances": "Pretraži izglede ({count})", "render.setup_legacy_environment": "Ova starija postavka koristi naslijeđenu rotaciju ili intenzitet okruženja. Te vrijednosti zadržavaju se; izričito ih pretvorite prije spremanja punog posla slike.", "render.setup_name": "Naziv postavke", @@ -2782,7 +3138,22 @@ "render.shell_tab_camera": "KAMERA RENDERA", "render.shell_tab_lighting": "OSVJETLJENJE", "render.shell_tab_scene": "SCENA RENDERA", + "render.shot_aperture": "Otvor blende", + "render.shot_distance": "Udaljenost", + "render.shot_dof": "Dubinska oštrina", "render.shot_duplicate": "Dupliciraj spremljeni kadar", + "render.shot_edit_output": "Uredi u Izlazu", + "render.shot_exposure_reason": "Ekspozicija zahtijeva CPU praćenje zraka ili GPU praćenje putanja.", + "render.shot_exposure_section": "Ekspozicija", + "render.shot_focus_distance": "Udaljenost fokusa", + "render.shot_fov_reason": "Vidno polje zahtijeva perspektivnu projekciju.", + "render.shot_frame_subject": "Uokviri predmet", + "render.shot_invalid_camera": "Vrijednosti kamere moraju biti konačne i unutar raspona.", + "render.shot_lens": "Objektiv", + "render.shot_lens_enable_reason": "Omogućite dubinsku oštrinu kako biste uredili otvor blende i udaljenost fokusa.", + "render.shot_lens_gpu_reason": "Dubinska oštrina zahtijeva GPU praćenje putanja.", + "render.shot_lens_perspective_reason": "Dubinska oštrina zahtijeva perspektivnu projekciju.", + "render.shot_look_selection": "Pogledaj odabir", "render.shot_manager": "Spremljeni kadrovi", "render.shot_new_name": "Naziv novog kadra", "render.shot_remove": "Ukloni spremljeni kadar", @@ -2790,6 +3161,9 @@ "render.shot_remove_title": "Ukloniti spremljeni kadar?", "render.shot_rename": "Preimenuj spremljeni kadar", "render.shot_saved_only": "Najprije spremite trenutni nacrt. Dupliciranje i preimenovanje čuvaju točne spremljene opcije i reference na resurse.", + "render.shot_selection_reason": "Odaberite tijelo ili lice prema kojem se usmjerava.", + "render.shot_size": "Veličina", + "render.shot_target": "Cilj", "render.shots_camera_changed": "Kamera je promijenjena otkako je ovaj kadar spremljen", "render.shots_name_help": "Upišite jedinstven naziv trenutnog kadra.", "render.shots_no_result": "Još nema renderiranja", @@ -3052,6 +3426,7 @@ "select an axis": "odaberite os", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "prvo odaberite os, ravan ili kružni brid ili lice valjka, stošca ili torusa", "select bodies first": "prvo odaberite tijela", + "select components first": "najprije odaberite komponente", "select corner points, or the two lines of one corner": "odaberite točke kutova ili dvije linije jednog kuta", "select one corner point, or the two lines of one corner": "odaberite jednu točku kuta ili dvije linije jednog kuta", "select sheet edges first": "prvo odaberite bridove lima", @@ -3070,6 +3445,7 @@ "settings.double_click_components": "Dvaput kliknite komponentu kako biste je aktivirali ili uredili povezanu komponentu na mjestu", "settings.frame_rate_details_note": "Dodaje graf vremena posljednjih okvira s brojem iscrtanih trokuta, histogram vremena okvira i što je GPU iscrtao. Kliknite broj okvira u sekundi u prikazu kako biste ih prikazali ili sakrili.", "settings.frame_rate_note": "Prikazuje broj okvira u sekundi u donjem desnom kutu prikaza.", + "settings.insert_lists_open_designs": "Umetni dizajn najprije navodi dizajne otvorene u drugim karticama", "settings.measure_axis_distances": "Prikaži udaljenosti X/Y/Z", "settings.measure_axis_distances_help": "Prikazuj komponente udaljenosti po svjetskim osima uz ukupnu udaljenost dok je Mjerenje otvoreno.", "settings.msaa_applies_on_restart": "Zaglađivanje rubova primjenjuje se pri sljedećem pokretanju SolidCrafta", @@ -3094,6 +3470,10 @@ "shortcut.unpin": "Otkvači", "sketch is missing": "skica nedostaje", "sketch.degrees_of_freedom": "Stupnjevi slobode: {count}", + "sketch.dimension_added_driven": "Skica već određuje tu kotu, pa je dodana kao referentna kota", + "sketch.dimension_applied": "Kota je primijenjena", + "sketch.dimension_applied_defined": "Kota je primijenjena; definiran je parametar `{name}`", + "sketch.dimension_applied_renamed": "Kota je primijenjena; parametar je nazvan `{name}`", "sketch.dof_left": "Preostali stupnjevi slobode: {count}", "sketch.dof_left_one": "Preostali stupnjevi slobode: {count}", "sketch.draw_curves": "Crtaj kao {linetype}", @@ -3128,6 +3508,8 @@ "sketch_text.use_font_file": "Upotrijebi datoteku fonta {path}", "solid": "puno", "status.editing": "Uređivanje: {name}", + "status.navigation": "Mapiranje miša: {mapping}", + "status.navigation_tip": "Kako miš pomiče prikaz. Kliknite za odabir drugog mapiranja", "status.under_constrained": "Nedovoljno ograničeno (stupnjevi slobode: {count})", "status.under_constrained_one": "Nedovoljno ograničeno (stupanj slobode: {count})", "status.units_tip": "Jedinice dizajna. Kliknite za promjenu", @@ -3161,8 +3543,10 @@ "timeline.delete_folders_tip": "Zadrži njihove značajke i ugniježđene mape u vremenskoj traci", "timeline.delete_folders_with_features_tip": "Izbriši mape sa svim značajkama u njima i njihove ugniježđene mape", "timeline.delete_many": "Izbriši odabrane značajke: {count}", + "timeline.edited_rolled_back": "Značajka {name} je promijenjena, ali je nakon oznake vraćanja pa se ne gradi: pomaknite oznaku iza nje kako biste vidjeli rezultat", "timeline.feature_error": "Pogreška: {message}", "timeline.feature_hint": "Dvaput kliknite za uređivanje · povucite za promjenu redoslijeda · desni klik za više", + "timeline.feature_rolled_back": "Vraćeno: nalazi se nakon oznake vraćanja, pa se ne gradi", "timeline.feature_warning": "Upozorenje: {message}", "timeline.group": "Nova mapa značajki", "timeline.move_many": "Premjesti stavke vremenske trake", @@ -3244,6 +3628,10 @@ "ui.3d_mouse_offer": "Kalibracija 3D miša ispravlja zamijenjene ili obrnute osi i postavlja njegovu brzinu i osjećaj.", "ui.3d_mouse_offer_never": "Ne prikazuj više", "ui.3d_mouse_offer_swap": "Prebaci na Hidraw (izravno)", + "ui.3d_mouse_orbit": "Orbitiranje", + "ui.3d_mouse_orbit_classic": "Klasično", + "ui.3d_mouse_orbit_tip": "Praćeno: 3D miš okreće prikaz oko dijela modela koji je u sredini prikaza kad okretanje počne, označenog blijedom kuglom. Klasično: oko središta prikaza", + "ui.3d_mouse_orbit_traced": "Praćeno", "ui.3d_mouse_reading": "Očitanje", "ui.3d_mouse_reset_axes": "Vrati zadanu dodjelu osi", "ui.3d_mouse_reset_view": "Vrati prikaz", @@ -3280,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Zakrenite kapicu suprotno od kazaljke na satu, držite je, a zatim pustite.", "ui.3d_mouse_switch_cap": "Kalibriraj kao Povratak u središte", "ui.3d_mouse_switch_trackball": "Kalibriraj kao kuglični miš", + "ui.3d_mouse_use_direct": "Koristi izravni upravljački program", + "ui.3d_mouse_use_direct_tip": "Čitaj 3D miš izravno (hidraw) umjesto preko spacenavd.", "ui.3d_mouse_view_axis": "Os prikaza", "ui.3d_mouse_view_off": "Isključeno", "ui.3d_mouse_view_off_tip": "Profil upravljačkog programa 3D miša isključuje ovu os, pa njezino kretanje ne pomiče prikaz. Odabir kretanja druge osi ovdje ih zamjenjuje, a to kretanje prestaje pomicati prikaz.", @@ -3596,6 +3986,7 @@ "ui.circle": "Kružnica", "ui.circular_pattern": "Kružni niz", "ui.clear": "Očisti", + "ui.clear_print_face": "Ukloni plohu ispisa", "ui.clear_recent": "Očisti nedavno", "ui.clear_the_selection": "Poništi odabir", "ui.click_selects_whole_bodies": "Klik odabire cijela tijela", @@ -3630,6 +4021,15 @@ "ui.constant": "Konstantno", "ui.constraints_conflict": "Ograničenja su u sukobu", "ui.construction": "Pomoćna", + "ui.construction.angle_hint": "Pomoćni pravac: kliknite točku, pa upišite kut ili odaberite smjer", + "ui.construction.bisector_hint": "Simetrala: odaberite dvije ravne linije koje se sijeku, pa željeno rješenje", + "ui.construction.common_hint": "Zajednička tangenta: odaberite dvije kružnice ili dva luka, pa željeno rješenje", + "ui.construction.contact_hint": "Tangenta: odaberite kružnicu/luk ili otvoreni spline/koniku, pa točku dodira (krivulje slobodnog oblika: krajnje točke)", + "ui.construction.external_hint": "Tangenta: odaberite kružnicu ili luk, vanjsku točku, pa željeno rješenje", + "ui.construction.free_hint": "Pomoćni pravac: kliknite dvije točke; desnim klikom počnite ispočetka", + "ui.construction.point_hint": "Pomoćni pravac: kliknite točku kroz koju prolazi", + "ui.construction.reference_hint": "Pomoćni pravac: odaberite ravnu liniju, pa točku; desnim klikom promijenite referencu", + "ui.construction.ribbon_label": "Pomoćne", "ui.context_menu": "Kontekstni izbornik", "ui.continuity_g0": "G0 pregib", "ui.continuity_g1": "G1 tangentno", @@ -3670,6 +4070,7 @@ "ui.design_properties.part_number": "Broj dijela", "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revizija", + "ui.design_saved": "Spremljeno: {path}", "ui.diameter": "Promjer", "ui.dimension": "Kota", "ui.direct_interaction": "Izravna interakcija", @@ -3720,6 +4121,14 @@ "ui.end_scale": "Krajnje skaliranje", "ui.entities": "Elementi", "ui.equal": "Jednakost", + "ui.equation_end": "Kraj", + "ui.equation_explicit": "Eksplicitni", + "ui.equation_form": "Oblik", + "ui.equation_note": "Varijabla je običan broj: sin(t) je uzima u radijanima. Rezultat bez jedinice izražen je u jedinici dizajna.", + "ui.equation_parametric": "Parametarski", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalentni polumjer", "ui.expand": "Proširi", "ui.exportDxf": "Izvezi DXF…", @@ -3785,6 +4194,7 @@ "ui.heading_design": "DIZAJN", "ui.heading_draft": "NAGIB", "ui.heading_edit": "UREDI", + "ui.heading_equation_curve": "KRIVULJA IZ JEDNADŽBE", "ui.heading_extrude": "EKSTRUDIRAJ", "ui.heading_fillet": "ZAOBLJENJE", "ui.heading_finish_editing": "ZAVRŠI UREĐIVANJE", @@ -3876,6 +4286,10 @@ "ui.import_ellipsis": "Uvezi…", "ui.infinite": "Beskonačno", "ui.insert": "Umetni", + "ui.insert_design.browse": "Pregledaj…", + "ui.insert_design.open_designs": "Otvoreni dizajni", + "ui.insert_design.saved_file": "{name} ima nespremljene promjene, pa je umetnuta njegova spremljena datoteka. Spremite ga, a zatim pokrenite Ažuriraj veze kako biste unijeli promjene.", + "ui.insert_design.unsaved": "Nespremljene promjene", "ui.inspect": "Pregledaj", "ui.intersect": "Presjek", "ui.isolate": "Izoliraj", @@ -4019,12 +4433,29 @@ "ui.polygon": "Mnogokut", "ui.preferences": "Postavke", "ui.press_a_key": "pritisnite tipku…", + "ui.print_face_cleared": "{body}: ploha ispisa uklonjena", + "ui.print_face_input": "Ploha ispisa", + "ui.print_face_one_body": "Ploha ispisa pripada jednom tijelu: pošaljite to tijelo samo da biste je promijenili", + "ui.print_face_pick": "Odaberite ravnu plohu punog tijela da postane ploha ispisa", + "ui.print_face_set": "{body}: ploha ispisa postavljena; Pošalji u slicer polaže tijelo na nju", "ui.profile": "Profil", "ui.profile_plane": "Ravnina profila", "ui.profiles": "Profili", "ui.project": "Projiciraj", "ui.project_face_edges_sketch": "Projiciraj bridove lica u nove skice", "ui.properties": "Svojstva", + "ui.properties.copy_matrix": "Kopiraj matricu", + "ui.properties.first_moments": "Statički momenti", + "ui.properties.inertia": "Moment tromosti", + "ui.properties.inertia_note": "Oko središta mase, u svjetskim osima (g·{unit}²)", + "ui.properties.inertia_unavailable": "Nije dostupno: mreža tijela nije zatvorena.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mjerenje…", + "ui.properties.measuring_failed": "Mjerenje je zaustavljeno prije završetka.", + "ui.properties.principal_axis": "Glavna os {number}", + "ui.properties.principal_moments": "Glavni momenti tromosti", "ui.pull_direction": "Smjer vađenja iz kalupa", "ui.quality": "Kvaliteta", "ui.quantity": "Broj", @@ -4070,6 +4501,7 @@ "ui.revolve": "Rotiraj", "ui.ribbon_exit": "Izlaz", "ui.ribbon_labels": "Prikaži tekst ispod ikona na traci", + "ui.ribbon_menus_skip_pinned": "Izostavi prikvačene naredbe iz padajućih izbornika", "ui.ribbon_pin_footer": "Prikvačene naredbe prikazuju se na traci. Zadržite pokazivač iznad retka kako biste ga prikvačili ili otkvačili.", "ui.ribbon_workspace": "Radni prostor", "ui.right": "Desno", @@ -4087,6 +4519,7 @@ "ui.select": "Odaberi", "ui.selectOther": "Odaberi drugo…", "ui.selectTangentChain": "Odaberi tangentni lanac", + "ui.select_all": "Odaberi sve", "ui.selection": "Odabir", "ui.selection_modifiers": "Modifikatori odabira", "ui.selection_shift_add_only": "Shift+klik dodaje bez uklanjanja", @@ -4123,7 +4556,11 @@ "ui.sketch": "Skica", "ui.sketch_dimension": "Kota skice", "ui.sketch_grid": "Mreža skice", + "ui.sketch_intersect_geometry": "Geometrija", + "ui.sketch_intersect_projection_link": "Veza projekcije", + "ui.sketch_intersect_selection_filter": "Filtar odabira", "ui.sketch_modifiers": "Modifikatori skice", + "ui.sketch_project_geometry": "Geometrija", "ui.sketch_scale": "Skaliranje skice", "ui.sketches": "Skice", "ui.slice": "Presijeci", @@ -4170,6 +4607,11 @@ "ui.symmetric": "Simetrično", "ui.symmetry": "Simetrija", "ui.system": "Sustav", + "ui.tab_assemble": "Sklopi", + "ui.tab_manage": "Upravljanje", + "ui.tab_sheet_metal": "Lim", + "ui.tab_view": "Prikaz", + "ui.tab_weldments": "Zavarene konstrukcije", "ui.tangent": "Tangentno", "ui.taper_angle": "Kut suženja", "ui.tapped": "S navojem", @@ -4185,6 +4627,7 @@ "ui.tip_angle": "Kut vrha", "ui.to_object": "Do objekta", "ui.tool_direction": "Smjer alata", + "ui.toolbar_tabs_hidden": "Sve kartice alatne trake su skrivene. Kliknite + ili ovdje desnom tipkom kako biste prikazali jednu.", "ui.top": "Odozgo", "ui.top_diameter": "Gornji promjer", "ui.top_radius": "Gornji polumjer", @@ -4222,6 +4665,8 @@ "ui.view_centre": "Središte prikaza", "ui.viewport_status_clear": "Isključi {name}", "ui.viewport_status_clear_hint": "Isključi {name} · Esc", + "ui.viewport_status_edges": "Bridovi: {count}", + "ui.viewport_status_from": "iz smjera {direction}", "ui.viewport_status_key": "Prikaži legendu za {name}", "ui.viewport_status_minimum_radius": "Minimalni polumjer", "ui.viewport_status_pairs": "Parovi: {count}", @@ -4231,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Osjenčano s bridovima", "ui.visual_style": "Vizualni stil", "ui.visual_wireframe": "Žičani model", + "ui.wheel_zoom_speed": "Brzina zumiranja kotačićem miša", "ui.width": "Širina", "ui.window_system": "Sustav prozora", "ui.window_system_auto": "Automatski", @@ -4261,7 +4707,9 @@ "update.button": "Ažuriraj", "update.button_version": "Ažuriraj na {version}", "update.check_automatically": "Automatski provjeravaj ažuriranja", + "update.check_for_updates": "Provjeri ažuriranja", "update.check_now": "Provjeri sada", + "update.check_unavailable": "Provjera ažuriranja nije dostupna za ovu instalaciju.", "update.checking": "Provjera…", "update.copy": "Kopiraj", "update.dmg_opened": "Slika diska je otvorena i provjerena: povucite SolidCraft u Programe, zamijenite staru kopiju, a zatim ponovno pokrenite SolidCraft.", @@ -4284,6 +4732,9 @@ "update.prefs_managed": "Ovo je SolidCraft {version}, kojim upravlja {manager}. Provjera pita GitHub za najnovije izdanje i ne šalje ništa drugo; ažurirajte SolidCraft u {manager}.", "update.prefs_note": "Ovo je SolidCraft {version} ({kind}). Provjera pita GitHub za najnovije izdanje i ne šalje ništa drugo.", "update.prefs_web": "Ponovno učitajte stranicu kako biste dobili najnoviju web aplikaciju. Ako mreža nije dostupna ili se ažuriranje ne može preuzeti na vrijeme, otvara se spremljena verzija; preuzimanje u tijeku nastavlja se za sljedeće ponovno učitavanje.", + "update.refresh_checking": "Provjera najnovijeg izdanja…", + "update.refresh_fallback": "Nije moguće provjeriti postoji li novije izdanje; ažurira se na {version}, pronađeno ranije.", + "update.refresh_selected": "Ažuriranje na {version}.", "update.release_page": "Otvori stranicu izdanja", "update.restart": "Ponovno pokreni sada", "update.restart_later": "Kasnije", diff --git a/assets/locales/hu.json b/assets/locales/hu.json index 111f9e84a1..c31fd3f48c 100644 --- a/assets/locales/hu.json +++ b/assets/locales/hu.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Itt jelennek meg a megnyitott vagy mentett tervek.", "Determined by To Object. Choose Angle to edit the angular extent.": "Az Objektumig érték határozza meg. A szögtartomány szerkesztéséhez válassza a Szög lehetőséget.", "Diameter": "Átmérő", + "Did this assembly open the wrong way up?": "Rossz tájolással nyílt meg ez az összeállítás?", + "Did this part open the wrong way up?": "Rossz tájolással nyílt meg ez az alkatrész?", "Dimension new shapes": "Új alakzatok méretezése", "Direction": "Irány", "Discard": "Elvetés", @@ -204,6 +206,7 @@ "Edges": "Élek", "Edit": "Szerkesztés", "Edit Profile Sketch": "Profilvázlat szerkesztése", + "Edit shortcuts": "Billentyűparancsok szerkesztése", "Enable rule": "Szabály engedélyezése", "Enabled": "Engedélyezve", "End Radius": "Végi sugár", @@ -466,6 +469,7 @@ "Radius": "Sugár", "Radius Fit": "Sugár illesztése", "Ratio": "Arány", + "Read-only list: shortcuts can't be changed here.": "Csak olvasható lista: a billentyűparancsok itt nem módosíthatók.", "Reattach selected face": "Kijelölt lap újrarögzítése", "Recover": "Helyreállítás", "Recover unsaved design?": "Helyreállítja a nem mentett tervet?", @@ -599,6 +603,7 @@ "Square": "Négyzet", "Stand it up": "Felállítás", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Felállítás: negyedfordulat az X körül, egyetlen visszavonható Mozgatás elemként.", + "Stand this assembly up?": "Felállítja ezt az összeállítást?", "Stand this part up?": "Felállítja ezt az alkatrészt?", "Start angle": "Kezdőszög", "Start bulge": "Kezdő ívelés", @@ -649,6 +654,7 @@ "Third point": "Harmadik pont", "This child is shared. Deleting it removes it from every copy of its parent.": "Ez a gyermekelem közös. Törlése eltávolítja a szülő minden másolatából.", "This child is shared. Position changes apply to every copy of its parent.": "Ez a gyermekelem közös. A pozícióváltozások a szülő minden másolatára érvényesek.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Ez a komponens egy többször elhelyezett részösszeállításban van: az igazítása minden elhelyezésben elmozdítja.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ez a lap nem biztosít tartós rögzítést. Válasszon másik lapot, vagy használjon kifejezetten rögzített síkot.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ez a kép még nincs csatolva a GitHubhoz. Másolja vagy mentse el, majd csatolja a GitHubon.", "Thread depth": "Menetmélység", @@ -769,6 +775,8 @@ "at the face": "a lapnál", "bom.group_children": "Részalkatrészek az alkatrészük sorában", "browser.body_name": "Test{number}", + "browser.calibrate_click_a": "Kattintson a képen az A pontra, vagy adja meg a koordinátáit.", + "browser.calibrate_click_b": "Kattintson a képen a B pontra, vagy adja meg a koordinátáit.", "browser.calibrate_title": "Kalibrálás: {name}", "browser.capture_many": "{count} komponens el lett mozgatva, de a pozíciójuk nincs rögzítve.", "browser.capture_note": "A rögzítés megtartja az új pozíciót; a visszaállítás visszateszi.", @@ -790,6 +798,11 @@ "cam.roller": "Görgő", "cam.roller_pick": "Görgő (opcionális)", "cam.tip_pick": "Csúcs (opcionális)", + "camera.shot_pitch": "Billentés", + "camera.shot_roll": "Gördülés", + "camera.shot_yaw": "Elfordulás", + "canvas.decal_place_hint": "Kattintson egy lapra a matrica elhelyezéséhez", + "canvas.place_hint": "Kattintson egy síkra vagy lapra a vászon elhelyezéséhez", "choose a profile": "válasszon profilt", "choose the cam's and the follower's joints": "válassza ki a bütyök és a követő csuklóit", "choose the joints to couple": "válassza ki az összekapcsolandó csuklókat", @@ -797,12 +810,16 @@ "click sketch curves": "kattintson vázlatgörbékre", "cmd.appearance_assign": "Megjelenés", "cmd.appearance_chain": "Megjelenéslánc", + "cmd.appearance_copy": "Stílus másolása", + "cmd.appearance_favorite": "Stílus kedvencnek jelölése", "cmd.appearance_library": "Megjelenéstár", "cmd.appearance_list": "Megjelenések listázása", "cmd.appearance_paint": "Megjelenés festése", + "cmd.appearance_paste": "Stílus beillesztése", "cmd.appearance_pick_up": "Megjelenés felvétele", "cmd.appearance_replace": "Megjelenés cseréje", "cmd.assembly_bom": "Anyagjegyzék", + "cmd.body.print_face": "Nyomtatási lap", "cmd.body_rename": "Test átnevezése", "cmd.browser_collapse_all": "Összes összecsukása", "cmd.browser_expand_all": "Összes kibontása", @@ -810,6 +827,7 @@ "cmd.browser_move": "Böngészőelemek áthelyezése", "cmd.browser_order": "Böngészőelemek rendezése", "cmd.browser_rename_group": "Csoport átnevezése", + "cmd.browser_reparent": "Áthelyezés komponensbe", "cmd.browser_ungroup": "Csoport szétbontása", "cmd.browser_visibility": "Megjelenítés/elrejtés", "cmd.canvas_calibrate": "Vászon kalibrálása", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Alkatrésztulajdonságok", "cmd.component_metadata_set": "Alkatrésztulajdonságok módosítása", "cmd.component_move_bodies": "Testek áthelyezése komponensbe", + "cmd.component_move_features": "Elemek áthelyezése komponensbe", "cmd.component_move_sketches": "Vázlatok áthelyezése komponensbe", + "cmd.component_paste_from_design": "Beillesztés másik tervből", "cmd.component_paste_new": "Beillesztés újként", "cmd.component_properties": "Komponens fizikai tulajdonságai", "cmd.component_relink": "Forrás újrakötése", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Mozgásnyom", "cmd.motion_trace_edit": "Mozgásnyom szerkesztése", "cmd.motion_trace_keep": "Mozgásnyom megtartása", + "cmd.occurrence_align": "Előfordulás igazítása", "cmd.occurrence_copy": "Beillesztés (példány)", "cmd.occurrence_drag": "Előfordulás húzása", "cmd.occurrence_ground": "Rögzítés a földhöz", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Felületkötés eltávolítása", "cmd.render_library_targets": "Renderkönyvtár célpontjainak keresése", "cmd.render_lights_add": "Fényforrás hozzáadása", + "cmd.render_lights_duplicate": "Renderfény kettőzése", + "cmd.render_lights_list": "Renderfények listázása", + "cmd.render_lights_mute": "Fényforrás némítása", + "cmd.render_lights_remove": "Renderfény eltávolítása", + "cmd.render_lights_set": "Renderfény beállítása", "cmd.render_lights_solo": "Szólófény", "cmd.render_optics_bind": "Optikai kötés mentése", "cmd.render_optics_unbind": "Optikai kötés eltávolítása", "cmd.render_output_size": "Render kimeneti méretének beállítása", "cmd.render_preflight": "Renderkérés ellenőrzése", + "cmd.render_preflight_copy": "Renderellenőrzési jelentés másolása", "cmd.render_preset_apply": "Natív Studio alkalmazása", "cmd.render_preset_resolve": "Natív Studio áttekintése", "cmd.render_quality_prepare": "Rögzített mintavételi keret áttekintése", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Kép renderelése", "cmd.render_status": "Renderelés előrehaladása", "cmd.select_clear": "Kijelölés törlése", + "cmd.settings_profile_apply": "Beállításprofil alkalmazása", + "cmd.settings_profile_delete": "Beállításprofil törlése", + "cmd.settings_profile_export": "Beállításprofil exportálása", + "cmd.settings_profile_import": "Beállításprofil importálása", + "cmd.settings_profile_list": "Beállításprofilok", + "cmd.settings_profile_rename": "Beállításprofil átnevezése", + "cmd.settings_profile_save": "Beállításprofil mentése", "cmd.sheet_convert": "Átalakítás lemezzé", "cmd.sheet_export_bend_table": "Hajlítási táblázat exportálása", "cmd.sheet_export_dxf": "Lapminta exportálása DXF-ként", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Újrahajlítás", "cmd.sheet_rules": "Lemezszabályok listázása", "cmd.sheet_unfold": "Kiterítés", + "cmd.sketch.construction_line": "Szerkesztőegyenes", + "cmd.sketch.construction_line.angle": "Adott szögű szerkesztőegyenes", + "cmd.sketch.construction_line.bisector": "Szögfelező", + "cmd.sketch.construction_line.horizontal": "Vízszintes szerkesztőegyenes", + "cmd.sketch.construction_line.parallel": "Párhuzamos szerkesztőegyenes", + "cmd.sketch.construction_line.perpendicular": "Merőleges szerkesztőegyenes", + "cmd.sketch.construction_line.tangent_at": "Érintő a görbe pontjában", + "cmd.sketch.construction_line.tangent_external": "Érintő külső pontból", + "cmd.sketch.construction_line.tangent_two": "Közös érintő", + "cmd.sketch.construction_line.vertical": "Függőleges szerkesztőegyenes", "cmd.sketch_arc3d": "3D ív", "cmd.sketch_arc_center_point": "Középpontos ív", "cmd.sketch_arc_tangent": "Érintő ív", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Rajzméret", "cmd.sketch_edit": "Vázlat szerkesztése", "cmd.sketch_edit_text": "Szöveg szerkesztése", + "cmd.sketch_equation_curve": "Egyenletgörbe", + "cmd.sketch_equation_curve_edit": "Egyenletgörbe szerkesztése", "cmd.sketch_export_dxf": "Mentés DXF-ként", "cmd.sketch_finish_auto_constrain": "Befejezés automatikus kényszerezéssel", "cmd.sketch_fit_curves_to_section": "Görbék illesztése a hálómetszethez", @@ -1198,6 +1244,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_items": "Nézetablak állapotelemei", "cmd.view_ambient_occlusion": "Környezeti takarás", "cmd.view_angle_readout": "Nézetszögek", "cmd.view_apply": "Elnevezett nézet visszaállítása", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Merőleges a kijelölésre", "cmd.view_previous_tab": "Előző lap", "cmd.view_rename": "Elnevezett nézet átnevezése", + "cmd.view_render_frame": "Renderkeret segédvonalai", "cmd.view_right": "Jobb oldali nézet", "cmd.view_save": "Új elnevezett nézet", "cmd.view_shots_strip": "Felvételsáv", @@ -1229,10 +1277,14 @@ "color.copy": "Kattintson a színértékek másolásához", "color.edit": "Kattintson a szín szerkesztéséhez", "color.gamma": "Színértékek megjelenítése 0–255 gamma térben", + "color.hex": "Hex", + "color.hex_help": "Adja meg: #rrggbb vagy #rrggbbaa; hat számjegy esetén az átlátszatlanság megmarad", + "color.hex_hint": "#rrggbb", "color.hue": "Árnyalat", "color.linear": "Színértékek megjelenítése 0–1 lineáris térben", "color.saturation_value": "Telítettség és érték", "color.selected": "Kijelölt szín", + "config.column_filter_empty": "Nincs egyező oszlop", "contact.components_count": "{count} komponens", "contact.enabled_label": "engedélyezve", "coupling.gear_1": "1. fogaskerék", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Kattintson egy másik hajtó méretre a neve beszúrásához", "dialog.dimension_name": "Méret: {name}", "dialog.edit_title": "{title} SZERKESZTÉSE", + "dialog.no_shown_body": "Nincs módosítható megjelenített test: jelenítsen meg egyet, vagy válassza az Új test lehetőséget", "dialog.select": "↖ Kijelölés", "dialog.selected_count": "↖ {count} kijelölve", "dialog.selection_guidance": "Válasszon ki egy bemenetet a módosításhoz. A kijelölt objektumokra kattintva eltávolíthatja őket; a Ctrl/Cmd-kattintás is váltja a kijelölést. Tekintse át az előnézetet, majd nyomja meg az OK gombot.", @@ -1346,8 +1399,20 @@ "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.mit": "MIT licenc", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Végtelen vízszintes szerkesztőegyenest hoz létre egy ponton át. Vízszintes kényszere a későbbi módosítások után is megmarad.", + "help.sketch.construction_line.parallel": "Válasszon ki egy egyenes vázlatvonalat, majd helyezzen el egy vele párhuzamos, végtelen szerkesztőegyenest. Másik referencia kiválasztásához kattintson jobb gombbal.", + "help.sketch.construction_line.perpendicular": "Válasszon ki egy egyenes vázlatvonalat, majd helyezzen el egy rá merőleges, végtelen szerkesztőegyenest. Másik referencia kiválasztásához kattintson jobb gombbal.", + "help.sketch.construction_line.tangent_at": "Érintőt hoz létre egy kör vagy körív pontjában, illetve egy nyitott spline vagy kúpgörbe végpontjában. Válassza ki a görbét, majd az érintési pontját.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Törlés", "hole_presets.delete_prompt": "Törli ezt a személyes előbeállítást? A meglévő furatok nem változnak.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Ezek az alkatrészek egyetlen komponens testei: {bodies}. A csukló egy komponenst mozgat a másikhoz képest, ezért minden alkatrésznek saját komponens kell.", "joint.rigid_group_one_component": "Mind a(z) {count} test egyetlen komponensben van (vagy magában a tervben): előbb használja a(z) {command} parancsot.", "joint.rigid_group_select_components": "Jelöljön ki két vagy több komponenst.", + "joint_move.blocked_hint": "Ellenőrizze a csukló típusát (például Merev), a földhöz rögzítést, a csuklókorlátokat és a paraméterrel vezérelt értékeket.", + "joint_move.blocked_title": "Ez az alkatrész nem mozgatható", + "joint_move.coarse": "Forgatási lépés a tengely közelében (°)", + "joint_move.distance": "Távolság a kezdőponttól", + "joint_move.done": "Kész", + "joint_move.fine": "Forgatási lépés a tengelytől távol (°)", + "joint_move.fixed": "Ennek az alkatrésznek nincs mozgatható csuklótengelye.", + "joint_move.help": "Mozgatás – húzzon egy nyilat vagy gyűrűt. Az Esc megszakítja az aktuális húzást.", + "joint_move.legacy_hint": "Kapcsolja ki az előző Mozgatás panel használatához.", + "joint_move.precision": "Finom forgatáshoz húzza a szaggatott segédvonalon kívülre.", + "joint_move.rotation": "Elforgatás a kezdettől", + "joint_move.select": "Jelöljön ki egy lapot vagy az összeállítás egy alkatrészét, majd válassza a Mozgatás parancsot.", + "joint_move.settings": "Összeállítás mozgatása", + "joint_move.step": "Lépés", + "joint_move.use_handles": "Csuklómozgató fogantyúk használata az összeállítás mozgatásához", "keymap.command_id": "Parancsazonosító", "keymap.empty": "Nincs egyező parancs", "keymap.use_anyway": "{key} használata itt mégis", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+bal húzás: keringés, Alt+középső húzás: pásztázás, Alt+jobb húzás: nagyítás; a középső húzás is pásztáz", "nav.openscad.help": "Bal húzás üres helyen: keringés, Shift+bal húzás: kijelölés kerettel, a vázlatgeometria húzása szerkeszt; jobb vagy középső húzás: pásztázás, Shift+jobb vagy Shift+középső húzás: nagyítás", "nav.perspective.sketch": "A vázlatszerkesztés ortografikus nézetet használ. A perspektívára váltáshoz fejezze be a vázlatot.", + "nav.right_drag_orbits.help": "Ki: a jobb gombos húzás nem mozgatja a kamerát, és a jobb gomb csak a helyi menüt nyitja meg. A SolidWorks egérkiosztás kiválasztása kikapcsolja.", + "nav.right_drag_orbits.label": "Jobb gombos húzás: keringés", "nav.solidworks.help": "Középső húzás: keringés, Ctrl+középső húzás: pásztázás, Shift+középső húzás: nagyítás, jobb húzás: keringés", "nav.tinkercad.help": "Jobb húzás: keringés, középső vagy Shift+jobb húzás: pásztázás", "nav.touchpad.help": "Tartsa lenyomva a Shiftet, és mozgassa a mutatót a pásztázáshoz; tartsa lenyomva az Alt/Option billentyűt, és mozgassa a keringéshez. Egérgomb nem szükséges. A függőleges görgetés nagyít.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Navigációs sáv testreszabása…", "navbar.custom.design": "Tervezés", "navbar.custom.duplicate": "Ez a hely már szerepel a sávon.", + "navbar.custom.edge": "Szél", + "navbar.custom.edge_bottom": "Alul", + "navbar.custom.edge_top": "Felül", "navbar.custom.fade": "Elhalványítás", "navbar.custom.full": "Újabb hely hozzáadásához távolítson el egyet.", "navbar.custom.hidden_design": "Rejtett a Tervezésben", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Vastagság", "parts.dimension.width": "Szélesség", "parts.family.bearing": "Mélyhornyú golyóscsapágy (befoglaló méret)", + "parts.family.button_head_cap_screw": "Belső kulcsnyílású lencsefejű csavar", "parts.family.dowel_pin": "Hengeres illesztőcsap", "parts.family.hex_bolt": "Hatlapfejű csavar", "parts.family.hex_nut": "Hatlapú anya", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Elsimítja a recés éleket.", "prefs.applies_on_restart": "Újraindításkor lép érvénybe", "prefs.assistant": "Asszisztens", + "prefs.auto_operation": "A művelet automatikus kiválasztása", + "prefs.auto_operation_note": "Az elemablakok aszerint választják az Egyesítés, Új test vagy Kivágás műveletet, hogy mit érint az új anyag, amíg Ön nem választ egyet. Kikapcsolva: megtartják a megnyitáskor kapott műveletet.", "prefs.axes": "Tengelyek", "prefs.axis": "Tengely", "prefs.bodies_folders": "Testmappák", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Alsó közép", "prefs.bottom_right": "Jobb alsó", "prefs.browser": "Böngésző", + "prefs.browser_eye_left": "Szem az összecsukó nyíl mellett", + "prefs.browser_eye_left_note": "A böngésző sorai a megjelenítés/elrejtés szemét az összecsukó nyíl mellett mutatják, mint korábban, nem a sorok jobb végén lévő oszlopban.", + "prefs.browser_reveal_on_hover": "Megjelenítés rámutatáskor", + "prefs.browser_show_note": "A Megjelenítve beállítás a képernyőn tartja a böngészőt. A Megjelenítés rámutatáskor elrejti, amíg a mutató a helye vagy a bal szélén lévő keskeny fül fölé nem kerül, és nyitva marad, amíg a mutató rajta van, vagy amíg egy belőle nyitott menü nyitva van.", + "prefs.browser_shown": "Megjelenítve", "prefs.built_in": "Beépített", "prefs.button_left": "Húzás bal gombbal", "prefs.button_middle": "Húzás középső gombbal", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "A SolidCraft újraindítása után lép életbe. A WGPU_ADAPTER_NAME felülbírálja ezt a választást.", "prefs.graphics_note": "Cserélje a sebességet simaságra. Minden választás hatását egy minta mutatja.", "prefs.graphics_presets_note": "Az előbeállítások az alábbi beállításokat együtt állítják be.", + "prefs.hide_constraint_glyphs_zoomed_out": "Kényszerjelek elrejtése kicsinyítéskor", + "prefs.hide_constraint_glyphs_zoomed_out_note": "A kényszerjelek eltűnnek a nézetből, ha a vázlat annyira ki van kicsinyítve, hogy zsúfolttá tennék. Kikapcsolva: minden nagyításnál változatlan képernyőméretűek maradnak.", "prefs.history": "Előzmények", "prefs.history_origin_bodies": "Az origó és a testek megjelenítése felül", "prefs.history_origin_bodies_note": "A bal oldali vagy a Böngésző alatti Előzmények az aktív komponens Testek mappájával és az Origóval kezdődik, szem ikonjaikkal és menüikkel, ahogy a Böngészőben. Az alul lévő Előzmények nem változik.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "A Kompakt a paneleket az ablak széleihez rögzíti. A Böngésző áttetszősége Lebegő módban érhető el. Az Asszisztens oldalát az Asszisztens oldalon módosíthatja.", "prefs.left": "Bal", "prefs.left_click": "Bal kattintás", + "prefs.line_drag_draws_one": "Lenyomás és húzás egy vonalat rajzol", + "prefs.line_drag_draws_one_note": "A Vonal eszközben a lenyomás, húzás és felengedés egyetlen vonalat rajzol a lenyomás helyétől a felengedés helyéig, és ott véget ér. A kattintások továbbra is láncot rajzolnak. Ki: a húzás a felengedés helyén pontot helyez el, ahogy a kattintás.", "prefs.linked_designs": "Kapcsolt tervek", "prefs.live_input": "Élő bevitel", "prefs.live_preview": "Élő előnézet", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "STEP- és importfájlok", "prefs.max_file_size_web": "A webalkalmazás legfeljebb 512 MB-os STEP- és importfájlokat, valamint 256 MB-os terveket nyit meg: egy böngészőlap nem tud nagyobb fájlokat tárolni. Az asztali alkalmazásban e korlátok növelhetők.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Nagy ikonok", + "prefs.menu_list": "Lista", + "prefs.menus": "Menük", "prefs.minutes": "perc", "prefs.minutes_suffix": " perc", "prefs.mouse": "Egér és érintőpad", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Elhelyezés", "prefs.prefer_free_sketch_points": "Átfedő pontoknál a mozgatható vázlatpontok előnyben részesítése", "prefs.preview_choices": "A választások előnézete", + "prefs.print_face_highlight": "Nyomtatási lap kiemelése", + "prefs.print_face_highlight_tip": "Milyen erősen színezi és körvonalazza a nézet a test nyomtatási lapját. 0 % esetén nincs kiemelés.", + "prefs.profile_active": "Aktív profil", + "prefs.profile_applied": "Profil alkalmazva.", + "prefs.profile_changed": "A beállítások a profil mentése óta megváltoztak. A Mentés eltárolja őket a profilban.", + "prefs.profile_deleted": "Profil törölve.", + "prefs.profile_export": "Exportálás…", + "prefs.profile_exported": "Profil exportálva.", + "prefs.profile_imported": "Profil importálva. Az átváltáshoz válassza ki az Aktív profil alatt.", + "prefs.profile_name_hint": "Profilnév, pl. Laptop", + "prefs.profile_rename": "Átnevezés", + "prefs.profile_renamed": "Profil átnevezve.", + "prefs.profile_save": "Mentés", + "prefs.profile_saved": "Az aktuális beállítások a profilba mentve.", + "prefs.profile_skipped": "Profil alkalmazva; {n} beállítás megtartotta az aktuális értékét (lásd a naplót).", + "prefs.profiles": "Beállításprofilok", + "prefs.profiles_note": "A billentyűparancsok, az eszköztár, a navigáció, a körmenü és a felület beállításai egy név alatt együtt tárolva, egyszerre váltva.", + "prefs.project_options_dialog": "Vetítési beállítások párbeszédpanel", + "prefs.project_options_dialog_note": "Be: vetítés előtt válassza ki a Geometria vagy a Testek lehetőséget és a Vetítési kapcsolatot. Ki: az előző, közvetlen kijelöléses munkamenet marad.", "prefs.quality_default": "alapértelmezett", "prefs.quality_fastest": "leggyorsabb", "prefs.quality_slowest": "leglassabb", "prefs.quality_smoother": "simább", + "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", + "prefs.ribbon_reveal": "Keresési találatok megmutatása a menüszalagon", + "prefs.ribbon_reveal_note": "Ha a Parancsok keresése egy találata fölé viszi a mutatót, megjelenik, hol található a parancs a menüszalagon: a füle, a gombja vagy a sora egy panel legördülő listájában. A keresésből indított parancs ott felvillan, a legördülő listából futó parancs pedig megjelöli a panelját.", "prefs.right_click": "Jobb kattintás", "prefs.saving": "Automatikus mentés és helyreállítás", "prefs.scale_note": "Az ablakban a Ctrl + / Ctrl − is használható. A gombok 100%-on 28 px-esek.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Nincs egyező beállítás", "prefs.select": "Kijelölés", "prefs.select_one_item": "Egy elem kijelölése", + "prefs.select_other_hidden": "Rejtett geometria kijelölése a Más kijelölése paranccsal", + "prefs.select_other_hidden_help": "Bekapcsolva: a mutató közelében lévő rejtett testeket, vázlatokat és takart éleket is tartalmazza. Kikapcsolva: a mutató alatti látható geometria eredeti listáját használja.", "prefs.selecting": "Kijelölés", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "A Shift csak hozzáad; a Ctrl / Cmd átkapcsol", "prefs.shift_add_note": "Be: a Shift hozzáad, a Ctrl / Cmd átkapcsol. Ki (alapértelmezett): mindkettő átkapcsol.", "prefs.shortcuts": "Billentyűparancsok", + "prefs.single_surface_analysis": "Egyetlen felületelemzés használata", + "prefs.single_surface_analysis_help": "Kikapcsolva a felületárnyalás és a Folytonosság egyszerre bekapcsolva maradhat. Bekapcsolva a legutóbb indított vagy módosított elemzés marad meg.", + "prefs.sketch_align_points": "Igazodás más vázlatpontokhoz rajzolás közben", + "prefs.sketch_align_points_note": "Rajzolás közben egy pont egyszerre igazodhat vízszintesen az egyik vázlatponthoz és függőlegesen egy másikhoz, pontozott segédvonallal, és a kattintás mindkét igazítást megtartja. Ki: csak az előző ponthoz, egyszerre egy tengely mentén.", "prefs.sketch_conflict_colour": "Ütköző geometria az ütközés színével", "prefs.sketch_conflict_colour_note": "Amíg egy vázlat kényszerei ütköznek, a hibás kényszer által megfogott görbék és pontok is az ütközés színével jelennek meg, nem csak a kényszer szimbóluma vagy mérete. Kikapcsolva: megtartják szokásos színüket.", + "prefs.sketch_construction_lines": "Bővített szerkesztőegyenes-eszközök", "prefs.sketch_exit_view": "Vázlat befejezésekor", "prefs.sketch_exit_view.keep": "A jelenlegi nézet megtartása", "prefs.sketch_exit_view.previous": "Visszatérés az előző nézethez", "prefs.sketch_exit_view.unless_rotated": "Visszatérés, ha nem lett forgatva", "prefs.sketch_exit_view_note": "Hová kerül a nézet a Vázlat befejezése, a Mégse vagy a dupla Esc után.", + "prefs.sketch_open_look_at": "Vázlat megnyitásakor a nézet a vázlatra fordul", + "prefs.sketch_open_look_at_note": "Ki: vázlat létrehozása vagy szerkesztése nem változtatja a nézetet. A Ránézés a vázlatra fordítja a nézetet, amikor szeretné.", + "prefs.sketch_recent_snap": "Automatikus illesztés a legutóbbi középpontokhoz és felezőpontokhoz", + "prefs.sketch_recent_snap_note": "Rajzolás közben a kurzor oda illeszkedik, ahol a két legutóbbi pont (kör középpontja vagy egyenes él felezőpontja) segédvonalai keresztezik egymást. Kikapcsolva: ezek a segédvonalak csak megjelennek, és a kurzor nem mozdul el.", "prefs.sketch_selection": "Vázlat és kijelölés", "prefs.sketch_selection_heading": "Vázlat és kijelölés", "prefs.sketch_selection_note": "Hogyan illeszkednek a vázlatok, és hogyan jelölnek ki a kattintások.", + "prefs.sketch_state_icons": "A vázlatok ikonjai az állapotuk színével", + "prefs.sketch_state_icons_note": "Az Előzményekben és a böngészőben a vázlat ikonja az állapotának színét veszi fel (alulkényszerezett, teljesen kényszerezett vagy ütköző), a buboréksúgója pedig megnevezi az állapotot. Kikapcsolva: minden vázlat ikonja egyforma.", "prefs.sketch_tab_always": "A Vázlat fül mindig látható", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Forrás", "prefs.speed": "Sebesség", "prefs.stand_up_note": "Miután egy STEP- vagy IGES-fájl Z felfelé tájolással megnyílik, negyedfordulat felajánlása az X körül.", + "prefs.stand_up_unknown": "Ismeretlen programokból származó fájloknál", + "prefs.stand_up_unknown.ask": "Rákérdezés párbeszédpanelben", + "prefs.stand_up_unknown.note": "Megjegyzés megjelenítése", + "prefs.stand_up_unknown.off": "Ne ajánlja fel", + "prefs.stand_up_unknown_note": "Az ajánlat módja, ha a fájl fejléce nem nevez meg olyan programot, amelyről ismert, hogy Y felfelé tájolással ment. Megjegyzés csak akkor jelenik meg, ha a fájl hanyatt fekvőnek látszik; a Rákérdezés párbeszédpanelben minden ilyen fájlnál rákérdez, mint korábban.", "prefs.status_bar": "Állapotsor", "prefs.status_bar_note": "Az ablak alsó szélén lévő vékony sáv megmutatja, hogy mit szerkeszt, a megnyitott vázlat állapotát a színével, valamint a terv egységeit; az egységekre kattintva módosíthatók. A vázlat állapota ilyenkor már nem jelenik meg a nézet sarkában.", "prefs.status_done": "Kész", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Értesítések", "prefs.toggle_item": "Az elem hozzáadása, vagy újbóli eltávolítása", "prefs.toolbar": "Eszköztár", + "prefs.toolbar_tabs": "Eszköztár fülei", "prefs.top": "Felső", + "prefs.trackpad_zoom_modifier": "Kétujjas görgetéses nagyítás módosítóbillentyűje", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Csak ezt a billentyűt tartsa lenyomva, hogy függőleges görgetéssel a mutatónál nagyítson. A Ki megtartja a jelenlegi görgetési viselkedést.", + "prefs.trackpad_zoom_modifier.off": "Ki", "prefs.translucent": "Áttetsző", "prefs.under_browser": "A Böngésző alatt", "prefs.under_cursor": "A kurzor alatt", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Nézetablak", + "prefs.viewport_analysis_status": "Elemzési állapot megjelenítése a nézetablak fölött", + "prefs.viewport_analysis_status_help": "A nézetablak fölött megjeleníti az aktív elemzéseket, minden elemből megnyitja a jelmagyarázatát, az Esc billentyűvel pedig kikapcsolja a legutóbbi elemzést. Kikapcsolva: visszaáll a sarokban lévő jelmagyarázat és az Esc korábbi működése.", + "prefs.viewport_background": "Háttér", + "prefs.viewport_background_colour": "Szín", + "prefs.viewport_background_theme": "A téma színátmenete", "prefs.wheel": "Görgő", "prefs.wheel_trackpad": "Görgő és érintőpad", "prefs.workspace": "Munkaterület", + "prefs.zoom_stop_at_model": "Nagyítás megállítása a modellnél", + "prefs.zoom_stop_at_model_tip": "A 3D-egér és az eszközalkalmazás a nézet közepén lévő felület felé nagyít; a húzásos nagyítás a húzás kezdőpontját tartja meg. Ki: a nagyítás áthaladhat a modellen. A görgő és a csippentés ettől függetlenül a Nagyítás a kurzorhoz beállítást követi", "prefs.zoom_toward_cursor": "Nagyítás a kurzor irányába", "properties.title": "Tulajdonságok: {name}", "recovery.days_ago": "{count} napja", "recovery.detail": "{file} · {features} elem · automatikusan mentve: {when}", + "recovery.discard_all": "Összes elvetése", + "recovery.discard_all_detail": "Ez a művelet nem vonható vissza.", + "recovery.discard_all_title": "{count} automatikus mentés elvetése?", + "recovery.discard_all_title_one": "1 automatikus mentés elvetése?", "recovery.hours_ago": "{count} órája", "recovery.just_now": "épp most", "recovery.min_ago": "{count} perce", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Súly", "render.appearance_physics_separate": "A vizuális megjelenés nem rendel hozzá sűrűséget, hőtani adatot vagy más mérnöki tulajdonságot.", "render.appearances": "Megjelenések", + "render.assign_help": "Kattintással a kijelölésre alkalmazza; húzással előnézetet kap és ráejtheti, kijelölés nélküli kattintással pedig festhet.", "render.availability_native": "Natív összeállítás; alkalmazás előtt tekintse át", "render.availability_projection": "Támogatott vetítés; lásd a hatókört és a kihagyásokat", "render.availability_reference": "Referenciaadat; nem automatikus hozzárendelés", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Lineáris csúcsfény-küszöb", "render.browse_capabilities": "Teljes képességkatalógus megtekintése", "render.browse_environments": "Eredeti HDR-környezetek böngészése", + "render.browse_looks": "Tallózás…", "render.browse_native_scenes": "Natív stúdiók böngészése", "render.browse_physical": "Fizikai referenciák megtekintése", "render.browse_quality": "Minőségi specifikációk megtekintése", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Átlátszóság: csak átlátszatlan felületek; a háttér alfa-csatornája támogatott.", "render.check_sorted_alpha": "Átlátszóság: rendezett alfa-közelítés.", "render.check_transmission": "Átlátszóság: fizikai áteresztés.", + "render.children.absolute_floor": "Abszolút küszöb", + "render.children.adaptive": "Adaptív", + "render.children.add_look_reason": "Adjon hozzá egy stílust az engedélyezett állapotának és erősségének szerkesztéséhez.", + "render.children.all_shots": "Összes felvétel", + "render.children.bloom": "Bloom", + "render.children.check": "Ellenőrzés", + "render.children.check_now": "Ellenőrzés most", + "render.children.chromatic": "Kromatikus", + "render.children.compact": "Kompakt", + "render.children.contrast": "Kontraszt", + "render.children.copy_image": "Másolás", + "render.children.copy_job": "Feladat JSON másolása", + "render.children.copy_request": "Kérés JSON másolása", + "render.children.coverage_error": "Lefedettségi hiba", + "render.children.cpu": "CPU-s sugárkövetés", + "render.children.denoise": "Zaj csökkentése a kész képen", + "render.children.depth": "Útmélység", + "render.children.distortion": "Torzítás", + "render.children.edge_samples": "Élminták", + "render.children.effects_banner": "Ezekhez az effektekhez GPU-s útkövetés szükséges. A megnyitás soha nem változtatja meg a módszert.", + "render.children.enable_adaptive": "Az adaptív korlátok szerkesztéséhez engedélyezze a független próbamintákat.", + "render.children.enable_bloom": "Az erősség, a küszöb és a sugár szerkesztéséhez engedélyezze a bloomot.", + "render.children.finishing": "Utómunka", + "render.children.flare": "Lencsefény", + "render.children.fresnel": "Fresnel-rétegzés", + "render.children.ghosts": "Szellemgyűrűk", + "render.children.glass": "Fényátvitel üvegben", + "render.children.go_to": "Ugrás: {editor}", + "render.children.grain": "Szemcsézet", + "render.children.halo": "Fényudvar", + "render.children.hdr": "HDR", + "render.children.keep_master": "Nyers lineáris mesterkép megőrzése", + "render.children.last_result": "Utolsó: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Lencseeffektusok", + "render.children.look_summary": "{tone} · {count} stílus", + "render.children.looks": "Kreatív stílusok", + "render.children.memory": "Memória", + "render.children.method": "Módszer", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "CPU-s sugárkövetést igényel. A megnyitás soha nem változtatja meg a módszert.", + "render.children.nested": "Igazolt egymásba ágyazott dielektrikumok", + "render.children.not_checked": "Nincs ellenőrizve", + "render.children.path": "Útkövetés", + "render.children.pilots": "Próbaminták", + "render.children.preview": "Előnézeti adatok", + "render.children.progressive_reason": "A mintaszám és a kezdőérték szerkesztéséhez válassza a Progresszív módot.", + "render.children.radius": "Sugár", + "render.children.reflect_depth": "Tükröződési mélység", + "render.children.reflect_samples": "Tükröződési minták", + "render.children.regrade": "Újbóli színkorrekció", + "render.children.relative_error": "Relatív hiba", + "render.children.render": "Renderelés", + "render.children.render_shot": "Felvétel renderelése", + "render.children.renderer": "Renderelő", + "render.children.renderers": "Renderelők", + "render.children.request": "Ez a kérés", + "render.children.reset_finishing": "Utómunka visszaállítása", + "render.children.review_quality": "Minőségi specifikáció áttekintése…", + "render.children.rgb_gain": "RGB-erősítés", + "render.children.samples": "Minták", + "render.children.sampling": "Mintavételezés", + "render.children.saturation": "Telítettség", + "render.children.scene_summary": "{bodies} test · {triangles} háromszög", + "render.children.seed": "Kezdőérték", + "render.children.sharpening": "Élesítés", + "render.children.single_image": "Egyetlen kép", + "render.children.soft_focus": "Lágy fókusz", + "render.children.streak": "Csík", + "render.children.strength": "Erősség", + "render.children.studio": "Studio", + "render.children.threshold": "Küszöb", + "render.children.tone": "Tónus", + "render.children.under_hood": "A motorháztető alatt", + "render.children.vignette": "Vignetta", + "render.children.workstation": "Munkaállomás", + "render.choose_look": "Először válasszon stílust a Megjelenések között", "render.chromatic_offset": "Kromatikus éleltolás", "render.clear_stage": "Mentett díszlet eltávolítása", "render.copy_request": "JSON másolása", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Fénycsúcsok lencsefény-erőssége", "render.flare_threshold": "Lencsefény küszöbértéke (jelenet-lineáris)", "render.fold_browser": "Böngésző összecsukása", + "render.frame_aspect": "Képarány", + "render.frame_centre_mark": "Középjel", + "render.frame_copy_limit": "Nincs elérhető név a felvétel másolatához", + "render.frame_copy_name": "{name} – másolat {number}", + "render.frame_custom": "Egyéni", + "render.frame_distance_unit": " × méret", + "render.frame_duplicate": "Kettőzés", + "render.frame_exposure": "Expozíció", + "render.frame_fov": "Látószög", + "render.frame_guides": "Segédvonalak", + "render.frame_invalid_aspect": "Érvénytelen képarány-előbeállítás", + "render.frame_lens": "Látószög {fov}° · ≈ {mm} mm", + "render.frame_name": "Név", + "render.frame_output_size": "Kimeneti méret", + "render.frame_photo": "3:2 · Fotó", + "render.frame_portrait": "4:5 · Portré", "render.frame_presets": "Képkivágási előbeállítások", "render.frame_presets_pending": "A Felvétel szerkesztővel érkezik", + "render.frame_projection": "Vetítés", + "render.frame_render_shot": "E felvétel renderelése", + "render.frame_rule_thirds": "Harmadolási szabály", + "render.frame_safe_area": "Biztonságos terület", + "render.frame_shot_title": "Felvétel · {name}", + "render.frame_square": "1:1 · Négyzet", + "render.frame_tall": "9:16 · Álló", + "render.frame_thirds": "Harmadok", + "render.frame_wide": "16:9 · Szélesvásznú", "render.gpu_depth": "Maximális útvonalmélység", "render.gpu_options_help": "A mintaszám, a mélység, a kezdőérték és az expozíció ezzel a beállítással együtt mentődik. Csak az aktív képalkotási módszer mentődik.", "render.gpu_path_failed": "A GPU-s útkövető nem érhető el", @@ -2360,6 +2627,7 @@ "render.heading_results": "EREDMÉNYEK", "render.heading_shots": "FELVÉTELEK", "render.hide_shots_label": "Felvételsáv elrejtése", + "render.hover_look": "{object} · {look} · kattintson a kijelöléshez", "render.image_advanced": "Speciális GPU-vezérlők", "render.image_cancelled": "Renderelés megszakítva", "render.image_cancelling": "Megszakítás", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Egységes közelítés használata térbeli vagy speciális effektek nélkül", "render.library_usable": "Futtatható vetületek", "render.library_users": "Használó testek", + "render.light_aim": "Célzás erre", "render.light_anchor_pending": "A fények koordináta-rendszerének módosítása még nem támogatott.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "A fények szerkesztéséhez válasszon CPU-s sugárkövetést vagy egy kifejezett GPU-stúdiót. A régi GPU-stúdiófények csak olvashatók maradnak.", + "render.light_behaviour": "VISELKEDÉS", + "render.light_default_fill": "Derítőfény", + "render.light_default_key": "Főfény", + "render.light_default_number": "Fény {number}", + "render.light_default_rim": "Kontúrfény", + "render.light_delete": "Törlés", + "render.light_distance": "Távolság", + "render.light_duplicate": "Kettőzés", + "render.light_elevation": "Magassági szög", + "render.light_gpu_punctual": "A pont- és spotfények CPU-s sugárkövetéssel érhetők el.", + "render.light_hard_shadow": "Ennek a fénynek éles árnyékai vannak; a fényforrás lágysága nem érhető el.", + "render.light_intensity": "Intenzitás", + "render.light_missing": "Ez a fény már nem létezik. Jelöljön ki egy fényt a Böngészőben.", + "render.light_place_highlight": "Elhelyezés csúcsfény alapján", + "render.light_place_pending": "A csúcsfény szerinti elhelyezéshez fénykijelölés szükséges a nézetablakban, ez még nem támogatott.", + "render.light_position": "POZÍCIÓ", + "render.light_preview_help": "Az élő nézet árnyék nélküli; a területi fények középponti mintás közelítést használnak. A renderelés a teljes fénymodellt használja.", + "render.light_product_centre": "Termék középpontja", + "render.light_size": "Méret", + "render.light_softness": "Lágyság", + "render.light_temperature": "Színhőmérséklet", + "render.light_temperature_help": "Közelítő feketetest-színezet; lineáris RGB-ként mentődik.", + "render.light_type": "Típus", + "render.light_type_area": "Területi fény", + "render.light_type_point": "Pontfény", + "render.light_type_spot": "Spotfény", + "render.light_type_sun": "Nap", + "render.light_view_only": "A szóló és a némítás erre a nézetre és a belőle készült renderekre hat; soha nem mentődik a tervbe.", + "render.light_world_origin": "Világ origója", + "render.lighting_aim_point": "Célpont", + "render.lighting_apply": "Előbeállítás alkalmazása", + "render.lighting_area_samples": "Területi minták", + "render.lighting_budget": "Keret", + "render.lighting_budget_help": "A darabszámok csak olvashatók. Módosításukhoz adjon hozzá, kettőzzön vagy töröljön fényeket; a korlátokat a módszer határozza meg.", + "render.lighting_colour": "Szín", + "render.lighting_consent": "Az irányfények cseréje erre az előbeállításra", + "render.lighting_custom": "Egyéni fények", + "render.lighting_custom_help": "Az Egyéni választása az előbeállítást szerkeszthető fényekké alakítja, mindegyiket egy-egy sorként a fában.", + "render.lighting_direction": "Irány X Y Z", + "render.lighting_direction_help": "Vagy írjon be egy, a fény felé mutató irányt (X Y Z).", + "render.lighting_distance_unit": " × méret", + "render.lighting_edge_u": "U oldalvektor", + "render.lighting_edge_v": "V oldalvektor", + "render.lighting_falloff": "Csillapítás", + "render.lighting_falloff_help": "Az intenzitás a referenciatávolságra vonatkozik.", + "render.lighting_height": "V magasság", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Fény némítása", + "render.lighting_point": "Pont", + "render.lighting_preset": "Stúdió-előbeállítás", + "render.lighting_preset_cpu": "Az irányfényes stúdió-előbeállításokhoz CPU-s sugárkövetés szükséges. A GPU-s megvilágítás kifejezett Nap- és területi fényeket használ.", + "render.lighting_preset_row": "Előbeállítás", + "render.lighting_radiance": "Sugársűrűség", + "render.lighting_reference": "Referencia", + "render.lighting_review_help": "Lecseréli az irányfények listáját. Megtartja a pont- és területi fényeket, a környezetet, az árnyékokat, a kamerát és a felvétel összes többi beállítását.", + "render.lighting_review_title": "Megvilágítási előbeállítás áttekintése", + "render.lighting_samples_cpu": "A területi fények mintái a CPU-s sugárkövetésre vonatkoznak.", + "render.lighting_samples_need_area": "A területi minták beállításához adjon hozzá egy CPU-s területi fényt.", + "render.lighting_samples_reason": "A területi minták száma 1, 4, 16 vagy 64 lehet.", + "render.lighting_shadow_cpu": "Az árnyékkapcsolóhoz CPU-s sugárkövetés szükséges. A GPU-s útkövetés mindig vet árnyékot.", + "render.lighting_shadows": "Árnyékvetés", + "render.lighting_shadows_section": "Árnyékok és mintavételezés", + "render.lighting_shape": "Alak", + "render.lighting_solo": "Szóló: csak ez a fény", + "render.lighting_source": "Forrás", + "render.lighting_source_section": "Fényforrás", + "render.lighting_spot_preserved": "Ez a pontfény megtartja a mentett spotfénykúpját, amíg a típusát meg nem változtatják.", + "render.lighting_sun_distance": "A Nap iránya független a távolságtól és a célponttól.", + "render.lighting_unsupported": "nem támogatott", + "render.lighting_used": "{used} / {limit}", + "render.lighting_width": "U szélesség", + "render.lighting_world_position": "Világ X Y Z", "render.lights_fixed_to": "Fények rögzítve ehhez", "render.linear_help": "32 bites lebegőpontos RGBA-mastert őriz meg a zajszűrés, az expozíció, a tónusleképezés, a szűrők és a háttér előtt. Lineáris sRGB/D65; lefedettséggel előreszorzott. A többletmemória csak kifejezett engedéllyel használható. Nincs tömörítés vagy további passz.", "render.linear_retain": "Nyers lineáris EXR megőrzése", "render.linear_unavailable": "A kiválasztott befejezett képkockához nincs megőrzött EXR. Renderelés előtt engedélyezze a megőrzést; egy korábbi PNG-ből a nyers sugárzás nem állítható vissza.", "render.look_add": "Színstílus hozzáadása", + "render.look_applied": "Alkalmazva: {name}", "render.look_bleach": "Bleach bypass ihletésű", "render.look_cool": "Hideg", "render.look_cyanotype": "Cianotípia ihletésű", "render.look_faded": "Kifakult nyomat", + "render.look_material": "Anyag", "render.look_mono": "Monokróm", + "render.look_pasted": "Stílus beillesztve", "render.look_sepia": "Szépia", "render.look_strength": "Stílus erőssége", "render.look_teal_orange": "Kékeszöld / narancs", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Speciális", "render.output_backend_ready": "Kész", "render.output_check": "Ellenőrzés", + "render.output_check_failed": "Ellenőrzés · hiba", "render.output_check_help": "Az Ellenőrzés futtatásával a kérés renderelés indítása nélkül átnézhető.", "render.output_check_summary": "Ellenőrzés · Megjegyzések: {count}", "render.output_copy": "Másolás", + "render.output_copy_report": "Jelentés másolása", "render.output_destination": "Célhely", "render.output_destination_help": "A kész kép exportálásakor válassza ki a célfájlt.", "render.output_dimensions": "Méret", @@ -2604,6 +2952,7 @@ "render.output_fit": "Illesztés", "render.output_format": "Formátum", "render.output_format_status": "A PNG a kész megjelenített kép pontos mása. A választható GPU-s EXR a megjelenítési szűrők előtti nyers, jelenetlineáris FLOAT32 sugárzást őrzi meg. Az exportálás sosem helyettesíti az előnézeti gyorsítótárat vagy egy másik feladatot.", + "render.output_grading": "Színkorrekció", "render.output_image": "Kép", "render.output_largest": "max. {width} × {height}", "render.output_lock_ratio": "Képarány zárolása", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "várakozás a minőségi specifikációra", "render.output_ratio_refused": "A kapcsolt méret meghaladja ennek a módszernek a mérethatárát. Az előző méret megmarad; oldja fel az arányt, vagy válasszon kisebb értéket.", "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_ok": "Méret rendben · {width} × {height} px", "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.", @@ -2629,6 +2980,7 @@ "render.page_output": "Kimenet", "render.page_scene": "Jelenet", "render.paint_label": "Festék", + "render.painting_with": "Festés ezzel: {name}", "render.passive_stage": "Padló és passzív díszlet", "render.passive_stage_help": "A véges méretű passzív panelek csak a renderelt képekhez tartoznak. Nem adnak hozzá CAD-testet vagy tömeget. Az elhelyezés alkalmazás után rögzített; ha metszik a termék határait, a program elutasítja. A CPU nem biztosít diffúz, közvetett megvilágítást.", "render.perspective": "Perspektivikus kamera", @@ -2650,6 +3002,7 @@ "render.pick_help": "Válasszon a Test, a Lap és a Komponens közül. Az üres területre kattintva a kijelölés törlődik; a Shift vagy a Ctrl hozzáad, illetve átkapcsol. A kiemelések csak az élő terméknézetben jelennek meg.", "render.pick_label": "Kijelölés", "render.pick_up_label": "Átvétel", + "render.pick_up_prompt": "Kattintson egy objektumra a stílusa felvételéhez", "render.pick_waiting": "A termék kijelölése még nem támogatott.", "render.post_contrast": "Kontraszt", "render.post_gain_b": "Kék erősítés", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Radiális torzítás", "render.radial_fit": "Termék illesztése", "render.radial_save": "Felvétel mentése", + "render.recent_looks": "Legutóbbi stílusok", "render.regrade_action": "Nyers mesterkép újbóli színkorrekciója (zajszűrés nélkül)", "render.regrade_help": "Külön PNG-t hoz létre a kiválasztott, kész nyers mesterképből, ezekkel a prezentációs beállításokkal. Nem számít sugarakat, nem módosítja a rögzített méretet, kamerát és fényeket, és nem írja felül az EXR-t. A zajszűrés nem kerül újra alkalmazásra: a nyers mesterképek nem őrzik meg a vezetőpuffereit. A származtatott kép ugyanazokkal a feladatvezérlőkkel exportálható.", "render.regrade_label": "Újbóli színkorrekció", "render.reload_settings": "Mentett beállítások újratöltése", + "render.remove_override": "Felülbírálás eltávolítása", + "render.replace_all": "Összes cseréje", "render.replace_ground": "A meglévő díszlet cseréjének megerősítése", "render.replace_label": "Csere", "render.replace_saved_surface": "Az ehhez a célhoz tartozó meglévő mentett kötés cseréje", + "render.replace_with": "Csere erre", "render.restore_camera": "Mentett kamera visszaállítása", "render.result_actual": "100%", "render.result_actual_help": "Egy képpont jut egy kijelzőképpontra.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Mentett kötés eltávolítása", "render.saved_surfaces": "Mentett felületkötések", "render.saved_surfaces_help": "A rögzített beépített térképek a kiválasztott előfordulást követik, helyi milliméterekben. A lecserélt vagy hiányzó cél hiba, soha nem automatikus újrahozzárendelés.", + "render.scene_all_shots": "Összes felvétel ({count})", "render.scene_apply": "Áttekintett stúdió alkalmazása", + "render.scene_batch_reason": "Az Összes felvételhez a mentett felvételek renderelési sora szükséges, amely még nem érhető el.", + "render.scene_check_now": "Ellenőrzés most", + "render.scene_check_passed": "Ellenőrzés sikeres · {notes} megjegyzés", + "render.scene_check_stale": "Az ellenőrzés elavult · ellenőrizze újra", + "render.scene_check_unchecked": "Még nincs ellenőrizve · 0 megjegyzés", "render.scene_consent": "A megvilágítás, a környezet, az expozíció és a díszlet cseréjének megerősítése", + "render.scene_light_summary": "{environment} · {lights} fény", + "render.scene_look_summary": "{looks} stílus · {overrides} felülbírálás", + "render.scene_open_check": "Ellenőrzés megnyitása", "render.scene_preserved_fields": "A kamera, a mintavételezés, a méretek, az optikai kötések, a CAD-geometria és a mérnöki anyagok megmaradnak.", "render.scene_preserves_design": "Megvilágítás és passzív díszlet összeállítása a látható tárgyhoz. A geometria, a mérnöki anyagok és a kamera külön marad.", + "render.scene_ready": "Készen áll a renderelésre", + "render.scene_reminder": "A Jelenet megőrzi a tervet. A Renderelésből soha nem módosul a geometria, a mérnöki tulajdonságok és a CAD-modell.", + "render.scene_reminders": "Emlékeztetők", + "render.scene_renderer_ready": "{renderer} kész", "render.scene_replaces": "Lecseréli", "render.scene_review": "Feloldás és áttekintés", "render.scene_review_help": "Az alkalmazás előtt tekintse át a teljes natív kompozíciót. A nem kötött, speciális JELENET-specifikációk nem kerülnek helyettesítésre.", "render.scene_scope_details": "A kompozíció hatóköre", + "render.scene_stages": "Szakaszok", "render.search_appearances": "Keresés {count} megjelenés között", "render.setup_legacy_environment": "Ez a régebbi beállítás örökölt környezetforgatást vagy -intenzitást használ. Ezek az értékek megmaradnak; teljes képfeladat mentése előtt kifejezetten alakítsa át őket.", "render.setup_name": "Beállítás neve", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "RENDERELÉS: KAMERA", "render.shell_tab_lighting": "MEGVILÁGÍTÁS", "render.shell_tab_scene": "RENDERELÉS: JELENET", + "render.shot_aperture": "Rekesznyílás", + "render.shot_distance": "Távolság", + "render.shot_dof": "Mélységélesség", "render.shot_duplicate": "Mentett felvétel kettőzése", + "render.shot_edit_output": "Szerkesztés a Kimenetben", + "render.shot_exposure_reason": "Az expozícióhoz CPU-s sugárkövetés vagy GPU-s útkövetés szükséges.", + "render.shot_exposure_section": "Expozíció", + "render.shot_focus_distance": "Fókusztávolság", + "render.shot_fov_reason": "A látószöghöz perspektivikus vetítés szükséges.", + "render.shot_frame_subject": "Tárgy kadrírozása", + "render.shot_invalid_camera": "A kameraértékeknek végesnek és a tartományon belülinek kell lenniük.", + "render.shot_lens": "Objektív", + "render.shot_lens_enable_reason": "A rekesznyílás és a fókusztávolság szerkesztéséhez engedélyezze a mélységélességet.", + "render.shot_lens_gpu_reason": "A mélységélességhez GPU-s útkövetés szükséges.", + "render.shot_lens_perspective_reason": "A mélységélességhez perspektivikus vetítés szükséges.", + "render.shot_look_selection": "Ránézés a kijelölésre", "render.shot_manager": "Mentett felvételek", "render.shot_new_name": "Új felvétel neve", "render.shot_remove": "Mentett felvétel eltávolítása", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Eltávolítja a mentett felvételt?", "render.shot_rename": "Mentett felvétel átnevezése", "render.shot_saved_only": "Előbb mentse az aktuális tervezetet. A kettőzés és az átnevezés megőrzi a pontos mentett beállításokat és erőforrás-hivatkozásokat.", + "render.shot_selection_reason": "Jelöljön ki egy testet vagy lapot, amelyre célozni szeretne.", + "render.shot_size": "Méret", + "render.shot_target": "Cél", "render.shots_camera_changed": "A kamera megváltozott a felvétel mentése óta", "render.shots_name_help": "Adjon egyedi nevet az aktuális felvételnek.", "render.shots_no_result": "Még nincs renderelés", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "még nem támogatott: a termék kijelölése és a megjelenés mintavételezése", "render.wait_replace": "még nem támogatott: egy megjelenés összes előfordulásának lecserélése", "render.wait_solo": "még nem támogatott: a fényforrások láthatósága és a szólóvezérlők", + "render.wear_applies_to": "Alkalmazás erre", + "render.wear_layer_count": "{count} réteg", + "render.wear_layer_one": "{count} réteg", + "render.wear_layers": "Rétegek", + "render.wear_no_layers": "Nincsenek rétegek", + "render.wear_selected_layer": "Kijelölt réteg", + "render.wear_title": "Kopás", "sample.bracket.about": "Alaplap peremekkel és szeggel 2 mm-es szabály szerint", "sample.bracket.name": "Lemezkonzol", "sample.enclosure.about": "Héjazott doboz perem, csavardudorok, támasz és pattintós illesztés", @@ -3030,6 +3426,7 @@ "select an axis": "jelöljön ki egy tengelyt", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "először jelöljön ki egy tengelyt, egy egyenes vagy kör alakú élt, illetve egy henger-, kúp- vagy tóruszlapot", "select bodies first": "először jelöljön ki testeket", + "select components first": "előbb jelöljön ki komponenseket", "select corner points, or the two lines of one corner": "jelöljön ki sarokpontokat, vagy egy sarok két vonalát", "select one corner point, or the two lines of one corner": "jelöljön ki egy sarokpontot, vagy egy sarok két vonalát", "select sheet edges first": "először jelöljön ki lemezéleket", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Kattintson duplán egy komponensre az aktiválásához, vagy egy kapcsolt komponens helyben szerkesztéséhez", "settings.frame_rate_details_note": "Grafikont ad az utolsó képkockák idejéről, rajta a megrajzolt háromszögekkel, valamint hisztogramot a képkockaidőkről és azt, amit a GPU megrajzolt. A nézetben a képkockasebességre kattintva megjelenítheti vagy elrejtheti őket.", "settings.frame_rate_note": "Megjeleníti a másodpercenkénti képkockaszámot a nézet jobb alsó sarkában.", + "settings.insert_lists_open_designs": "A Terv beszúrása először a más füleken megnyitott terveket listázza", "settings.measure_axis_distances": "X/Y/Z távolságok megjelenítése", "settings.measure_axis_distances_help": "A világtengely-összetevők méreteit is megjeleníti a teljes távolság mellett, amíg a Mérés nyitva van.", "settings.msaa_applies_on_restart": "Az élsimítás a SolidCraft következő indításakor lép érvénybe", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Rögzítés feloldása", "sketch is missing": "a vázlat hiányzik", "sketch.degrees_of_freedom": "{count} szabadságfok", + "sketch.dimension_added_driven": "A vázlat már meghatározza ezt a méretet, ezért vezérelt (referencia-) méretként lett hozzáadva", + "sketch.dimension_applied": "Méret alkalmazva", + "sketch.dimension_applied_defined": "Méret alkalmazva; a(z) `{name}` paraméter definiálva", + "sketch.dimension_applied_renamed": "Méret alkalmazva; a paraméter neve: `{name}`", "sketch.dof_left": "{count} szabadságfok maradt", "sketch.dof_left_one": "{count} szabadságfok maradt", "sketch.draw_curves": "Rajzolás mint {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "A(z) {path} betűtípusfájl használata", "solid": "szilárd test", "status.editing": "Szerkesztés: {name}", + "status.navigation": "Egérkiosztás: {mapping}", + "status.navigation_tip": "Hogyan mozgatja az egér a nézetet. Kattintson másik kiosztás választásához", "status.under_constrained": "Alulkényszerezett ({count} szabadságfok)", "status.under_constrained_one": "Alulkényszerezett ({count} szabadságfok)", "status.units_tip": "A terv egységei. Kattintson a módosításukhoz", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Az elemek és a beágyazott mappák megmaradnak az előzményekben", "timeline.delete_folders_with_features_tip": "A mappák törlése az összes bennük lévő elemmel és a beágyazott mappákkal együtt", "timeline.delete_many": "{count} kijelölt elem törlése", + "timeline.edited_rolled_back": "{name} módosult, de az előzményjelölő után van, ezért nem épül fel: az eredmény megtekintéséhez vigye a jelölőt utána", "timeline.feature_error": "Hiba: {message}", "timeline.feature_hint": "Dupla kattintás: szerkesztés · húzás: átrendezés · jobb kattintás: továbbiak", + "timeline.feature_rolled_back": "Visszagörgetve: az előzményjelölő után van, ezért nem épül fel", "timeline.feature_warning": "Figyelmeztetés: {message}", "timeline.group": "Új elemmappa", "timeline.move_many": "Idővonal-elemek áthelyezése", "timeline.move_to_group": "Elemek áthelyezése mappába", "timeline.rename_group": "Elemmappa átnevezése", "timeline.reorder_many": "Elemek átrendezése", + "timeline.suppress_features": "Elemek elnyomása", "timeline.suppress_folders_tip": "A mappákban és a beágyazott mappákban lévő összes elem elnyomása vagy elnyomásának megszüntetése", "timeline.ungroup": "Elemmappák eltávolítása", + "timeline.unsuppress_features": "Elemek elnyomásának megszüntetése", "to the face": "a laphoz", "to the next face": "a következő laphoz", "toolbar.customize_note": "{tab} lap. Jelöljön be egy parancsot, hogy eszköztárgombot kapjon. Húzhat egy panel legördülő menüjének sorát is az eszköztárra, húzhat egy gombot le az eszköztárról az eltávolításhoz, vagy használhatja a legördülő sor rögzítő tűjét.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "A 3D-egér kalibrálása kijavítja a felcserélt vagy megfordított tengelyeket, és beállítja a sebességét és az érzetét.", "ui.3d_mouse_offer_never": "Ne jelenjen meg többé", "ui.3d_mouse_offer_swap": "Váltás Hidrawra (közvetlen illesztőprogram)", + "ui.3d_mouse_orbit": "Keringés", + "ui.3d_mouse_orbit_classic": "Klasszikus", + "ui.3d_mouse_orbit_tip": "Célzott: a 3D-egér a nézetet a modell azon része körül forgatja, amely a forgatás kezdetekor a nézet közepén van; ezt egy halvány gömb jelöli. Klasszikus: a nézet középpontja körül", + "ui.3d_mouse_orbit_traced": "Célzott", "ui.3d_mouse_reading": "Beolvasás", "ui.3d_mouse_reset_axes": "Tengelykiosztás visszaállítása", "ui.3d_mouse_reset_view": "Nézet alaphelyzetbe állítása", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Csavarja el a kupakot az óramutató járásával ellentétesen, tartsa, majd engedje el.", "ui.3d_mouse_switch_cap": "Kalibrálás középre visszatérőként", "ui.3d_mouse_switch_trackball": "Kalibrálás trackballként", + "ui.3d_mouse_use_direct": "Közvetlen illesztőprogram", + "ui.3d_mouse_use_direct_tip": "A 3D-egér közvetlen olvasása (hidraw) a spacenavd helyett.", "ui.3d_mouse_view_axis": "Nézettengely", "ui.3d_mouse_view_off": "Ki", "ui.3d_mouse_view_off_tip": "A 3D-egér illesztőprogramjának profilja kikapcsolja ezt a tengelyt, ezért a mozgása nem mozgatja a nézetet. Ha itt egy másik tengely mozgását választja, a kettő felcserélődik, és az a mozgás már nem mozgatja a nézetet.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Mégse", "ui.canvases": "Vásznak", "ui.center": "Középpont", + "ui.center_arc_follows_cursor": "A középpontos ív követi a kurzor irányát", + "ui.center_arc_follows_cursor_tip": "A középpontos ív az óramutató járásával megegyező vagy azzal ellentétes irányban halad, aszerint, hogy a kurzor merre kerüli meg a középpontját. Kikapcsolva: mindig az óramutató járásával ellentétes irányban, a kezdőponttól.", "ui.centerline": "Középvonal", "ui.chain": "Lánc", "ui.chamfer": "Letörés", @@ -3570,6 +3986,7 @@ "ui.circle": "Kör", "ui.circular_pattern": "Körminta", "ui.clear": "Törlés", + "ui.clear_print_face": "Nyomtatási lap törlése", "ui.clear_recent": "Legutóbbiak törlése", "ui.clear_the_selection": "Kijelölés törlése", "ui.click_selects_whole_bodies": "A kattintás teljes testeket jelöl ki", @@ -3604,6 +4021,15 @@ "ui.constant": "Állandó", "ui.constraints_conflict": "Kényszerek ütköznek", "ui.construction": "Szerkesztő", + "ui.construction.angle_hint": "Szerkesztőegyenes: kattintson egy pontra, majd adjon meg szöget, vagy válasszon irányt", + "ui.construction.bisector_hint": "Szögfelező: válasszon két egymást metsző egyenest, majd a kívánt megoldást", + "ui.construction.common_hint": "Közös érintő: válasszon két kört/ívet, majd a kívánt megoldást", + "ui.construction.contact_hint": "Érintő: válasszon egy kört/ívet vagy nyitott spline-t/kúpgörbét, majd az érintési pontot (szabadformájú görbék: végpontok)", + "ui.construction.external_hint": "Érintő: válasszon egy kört vagy ívet, egy külső pontot, majd a kívánt megoldást", + "ui.construction.free_hint": "Szerkesztőegyenes: kattintson két pontra; jobb kattintással újrakezdheti", + "ui.construction.point_hint": "Szerkesztőegyenes: kattintson arra a pontra, amelyen át kell haladnia", + "ui.construction.reference_hint": "Szerkesztőegyenes: válasszon egy egyenest, majd egy pontot; jobb kattintással módosíthatja a referenciát", + "ui.construction.ribbon_label": "Segédvonalak", "ui.context_menu": "Helyi menü", "ui.continuity_g0": "G0 törés", "ui.continuity_g1": "G1 érintő", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Végi léptékezés", "ui.entities": "Elemek", "ui.equal": "Egyenlő", + "ui.equation_end": "Vég", + "ui.equation_explicit": "Explicit", + "ui.equation_form": "Alak", + "ui.equation_note": "A változó egyszerű szám: a sin(t) radiánban értelmezi. A mértékegység nélküli eredmény a terv mértékegységében értendő.", + "ui.equation_parametric": "Parametrikus", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Egyenértékű sugár", "ui.expand": "Kibontás", "ui.exportDxf": "DXF exportálása…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "TERVEZÉS", "ui.heading_draft": "DŐLÉS", "ui.heading_edit": "SZERKESZTÉS", + "ui.heading_equation_curve": "EGYENLETGÖRBE", "ui.heading_extrude": "KIHÚZÁS", "ui.heading_fillet": "LEKEREKÍTÉS", "ui.heading_finish_editing": "SZERKESZTÉS BEFEJEZÉSE", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Importálás…", "ui.infinite": "Végtelen", "ui.insert": "Beszúrás", + "ui.insert_design.browse": "Tallózás…", + "ui.insert_design.open_designs": "Megnyitott tervek", + "ui.insert_design.saved_file": "A(z) {name} nem mentett módosításokat tartalmaz, ezért a mentett fájlja lett beszúrva. Mentse el, majd a Kapcsolatok frissítése paranccsal hozza át a módosításokat.", + "ui.insert_design.unsaved": "Nem mentett módosítások", "ui.inspect": "Vizsgálat", "ui.intersect": "Metszet", "ui.isolate": "Elkülönítés", @@ -3994,12 +4433,29 @@ "ui.polygon": "Sokszög", "ui.preferences": "Beállítások", "ui.press_a_key": "nyomjon meg egy billentyűt…", + "ui.print_face_cleared": "{body}: nyomtatási lap törölve", + "ui.print_face_input": "Nyomtatási lap", + "ui.print_face_one_body": "A nyomtatási lap egy testhez tartozik: a módosításhoz küldd el azt a testet egyedül", + "ui.print_face_pick": "Válassz ki egy tömör test egy sík lapját, hogy az legyen a nyomtatási lap", + "ui.print_face_set": "{body}: nyomtatási lap beállítva; a Küldés szeletelőnek erre a lapra fekteti a testet", "ui.profile": "Profil", "ui.profile_plane": "Profilsík", "ui.profiles": "Profilok", "ui.project": "Vetítés", "ui.project_face_edges_sketch": "Lapélek vetítése új vázlatokba", "ui.properties": "Tulajdonságok", + "ui.properties.copy_matrix": "Mátrix másolása", + "ui.properties.first_moments": "Elsőrendű nyomatékok", + "ui.properties.inertia": "Tehetetlenségi nyomaték", + "ui.properties.inertia_note": "A tömegközéppontra, világtengelyekben (g·{unit}²)", + "ui.properties.inertia_unavailable": "Nem érhető el: egy test hálója nem zárt.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mérés…", + "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", "ui.pull_direction": "Húzási irány", "ui.quality": "Minőség", "ui.quantity": "Mennyiség", @@ -4045,6 +4501,7 @@ "ui.revolve": "Forgatás", "ui.ribbon_exit": "Kilépés", "ui.ribbon_labels": "Szöveg megjelenítése a menüszalag ikonjai alatt", + "ui.ribbon_menus_skip_pinned": "A rögzített parancsok kihagyása a legördülő menükből", "ui.ribbon_pin_footer": "A rögzített parancsok megjelennek a menüszalagon. Egy sor fölé víve rögzíthető vagy feloldható.", "ui.ribbon_workspace": "Munkaterület", "ui.right": "Jobb", @@ -4062,6 +4519,7 @@ "ui.select": "Kijelölés", "ui.selectOther": "Más kijelölése…", "ui.selectTangentChain": "Érintőlánc kijelölése", + "ui.select_all": "Összes kijelölése", "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", @@ -4098,7 +4556,11 @@ "ui.sketch": "Vázlat", "ui.sketch_dimension": "Vázlatméret", "ui.sketch_grid": "Vázlatrács", + "ui.sketch_intersect_geometry": "Geometria", + "ui.sketch_intersect_projection_link": "Vetítési kapcsolat", + "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_scale": "Vázlat léptéke", "ui.sketches": "Vázlatok", "ui.slice": "Szeletelés", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Kezelés", "ui.tab_sheet_metal": "Lemez", "ui.tab_view": "Nézet", + "ui.tab_weldments": "Hegesztés", "ui.tangent": "Érintő", "ui.taper_angle": "Kúposodási szög", "ui.tapped": "Menetes", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Csúcsszög", "ui.to_object": "Objektumig", "ui.tool_direction": "Szerszám iránya", + "ui.toolbar_tabs_hidden": "Az eszköztár összes füle rejtett. Egy megjelenítéséhez kattintson a + gombra, vagy kattintson ide a jobb gombbal.", "ui.top": "Felülnézet", "ui.top_diameter": "Felső átmérő", "ui.top_radius": "Felső sugár", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Nézet középpontja", "ui.viewport_status_clear": "Kikapcsolás: {name}", "ui.viewport_status_clear_hint": "Kikapcsolás: {name} · Esc", + "ui.viewport_status_edges": "{count} él", + "ui.viewport_status_from": "{direction} felől", "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", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Árnyalt élekkel", "ui.visual_style": "Vizuális stílus", "ui.visual_wireframe": "Drótváz", + "ui.wheel_zoom_speed": "Egérgörgős nagyítás sebessége", "ui.width": "Szélesség", "ui.window_system": "Ablakkezelő rendszer", "ui.window_system_auto": "Automatikus", @@ -4240,7 +4707,9 @@ "update.button": "Frissítés", "update.button_version": "Frissítés erre: {version}", "update.check_automatically": "Frissítések automatikus keresése", + "update.check_for_updates": "Frissítések keresése", "update.check_now": "Keresés most", + "update.check_unavailable": "Ehhez a telepítéshez nem érhető el a frissítések keresése.", "update.checking": "Keresés…", "update.copy": "Másolás", "update.dmg_opened": "A lemezkép meg van nyitva és ellenőrizve: húzza a SolidCraftot az Alkalmazások mappába, lecserélve a régi példányt, majd indítsa újra a SolidCraftot.", @@ -4254,13 +4723,18 @@ "update.installed": "A SolidCraft {version} telepítve. Indítsa újra a használatához.", "update.installer_started": "A telepítő a SolidCraft bezárásakor indul.", "update.later": "Emlékeztessen később", + "update.managed": "Frissítés érhető el: frissítse a SolidCraftot itt: {manager}", "update.no_notes": "Ehhez a kiadáshoz nincs megjegyzés.", "update.package_hint": "Ez a példány egy {kind} csomagból származik. Töltse le az új csomagot, majd telepítse ezzel:", "update.prefs_dev": "Ez fejlesztői build ({version}): nem keres frissítéseket.", "update.prefs_flatpak": "A Flatpak frissíti ezt a példányt ({version}): futtassa a(z) {command} parancsot, vagy használja a szoftverközpontot. A SolidCraft nem keres.", "update.prefs_label": "Frissítések", + "update.prefs_managed": "Ez a SolidCraft {version}, kezelője: {manager}. A keresés a GitHubtól kéri a legújabb kiadást, és semmi mást nem küld; a SolidCraftot itt frissítse: {manager}.", "update.prefs_note": "Ez a SolidCraft {version} ({kind}). A keresés a GitHubtól kéri a legújabb kiadást, és semmi mást nem küld.", "update.prefs_web": "Töltse újra az oldalt a legújabb webalkalmazás megszerzéséhez. Ha a hálózat nem érhető el, vagy a frissítés időben nem tud letöltődni, a mentett verzió nyílik meg; a folyamatban lévő letöltés a következő újratöltéshez folytatódik.", + "update.refresh_checking": "A legújabb kiadás keresése…", + "update.refresh_fallback": "Nem sikerült újabb kiadást keresni; frissítés a korábban talált {version} verzióra.", + "update.refresh_selected": "Frissítés erre: {version}.", "update.release_page": "Kiadási oldal megnyitása", "update.restart": "Újraindítás most", "update.restart_later": "Később", diff --git a/assets/locales/id.json b/assets/locales/id.json index 9c93853dd1..3b396007a2 100644 --- a/assets/locales/id.json +++ b/assets/locales/id.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Desain yang Anda buka atau simpan muncul di sini.", "Determined by To Object. Choose Angle to edit the angular extent.": "Ditentukan oleh To Object. Pilih Angle untuk mengedit rentang sudut.", "Diameter": "Diameter", + "Did this assembly open the wrong way up?": "Apakah rakitan ini terbuka dengan orientasi yang salah?", + "Did this part open the wrong way up?": "Apakah part ini terbuka dengan orientasi yang salah?", "Dimension new shapes": "Beri dimensi bentuk baru", "Direction": "Arah", "Discard": "Buang", @@ -204,6 +206,7 @@ "Edges": "Tepi", "Edit": "Edit", "Edit Profile Sketch": "Edit Sketsa Profil", + "Edit shortcuts": "Edit pintasan", "Enable rule": "Aktifkan aturan", "Enabled": "Aktif", "End Radius": "Radius Akhir", @@ -466,6 +469,7 @@ "Radius": "Radius", "Radius Fit": "Fit Radius", "Ratio": "Rasio", + "Read-only list: shortcuts can't be changed here.": "Daftar hanya-baca: pintasan tidak dapat diubah di sini.", "Reattach selected face": "Pasang ulang muka terpilih", "Recover": "Pulihkan", "Recover unsaved design?": "Pulihkan desain yang belum disimpan?", @@ -599,6 +603,7 @@ "Square": "Persegi", "Stand it up": "Tegakkan", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Tegakkan: putaran seperempat terhadap X, sebagai satu fitur Move yang dapat dibatalkan.", + "Stand this assembly up?": "Tegakkan rakitan ini?", "Stand this part up?": "Tegakkan part ini?", "Start angle": "Sudut awal", "Start bulge": "Bulge awal", @@ -649,6 +654,7 @@ "Third point": "Titik ketiga", "This child is shared. Deleting it removes it from every copy of its parent.": "Anak ini dibagikan. Menghapusnya akan menghilangkannya dari setiap salinan induknya.", "This child is shared. Position changes apply to every copy of its parent.": "Anak ini dibagikan. Perubahan posisi berlaku untuk setiap salinan induknya.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Komponen ini berada di dalam subrakitan yang ditempatkan lebih dari sekali: menyejajarkannya akan memindahkannya di setiap penempatan.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Muka ini tidak dapat menyediakan lampiran yang tahan lama. Pilih muka lain atau gunakan bidang tetap secara eksplisit.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Gambar ini belum dilampirkan ke GitHub. Salin atau simpan, lalu lampirkan di GitHub.", "Thread depth": "Kedalaman ulir", @@ -769,6 +775,8 @@ "at the face": "di muka", "bom.group_children": "Sub-part dalam baris part induknya", "browser.body_name": "Bodi{number}", + "browser.calibrate_click_a": "Klik gambar untuk Titik A, atau ketik koordinatnya.", + "browser.calibrate_click_b": "Klik gambar untuk Titik B, atau ketik koordinatnya.", "browser.calibrate_title": "Kalibrasi: {name}", "browser.capture_many": "{count} komponen dipindahkan tetapi posisinya belum ditangkap.", "browser.capture_note": "Tangkap menyimpan posisi baru; Kembalikan memulihkannya.", @@ -790,6 +798,11 @@ "cam.roller": "Rol", "cam.roller_pick": "Rol (opsional)", "cam.tip_pick": "Ujung (opsional)", + "camera.shot_pitch": "Pitch", + "camera.shot_roll": "Roll", + "camera.shot_yaw": "Yaw", + "canvas.decal_place_hint": "Klik sebuah muka untuk menempatkan decal", + "canvas.place_hint": "Klik sebuah bidang atau muka untuk menempatkan kanvas", "choose a profile": "pilih profil", "choose the cam's and the follower's joints": "pilih sambungan kam dan pengikutnya", "choose the joints to couple": "pilih sambungan yang akan dikopel", @@ -797,12 +810,16 @@ "click sketch curves": "klik kurva sketsa", "cmd.appearance_assign": "Tampilan", "cmd.appearance_chain": "Rantai Tampilan", + "cmd.appearance_copy": "Salin Tampilan", + "cmd.appearance_favorite": "Jadikan Tampilan Favorit", "cmd.appearance_library": "Pustaka Tampilan", "cmd.appearance_list": "Daftar Tampilan", "cmd.appearance_paint": "Cat Tampilan", + "cmd.appearance_paste": "Tempel Tampilan", "cmd.appearance_pick_up": "Ambil Tampilan", "cmd.appearance_replace": "Ganti Tampilan", "cmd.assembly_bom": "Daftar Material (BOM)", + "cmd.body.print_face": "Permukaan cetak", "cmd.body_rename": "Ganti Nama Bodi", "cmd.browser_collapse_all": "Ciutkan Semua", "cmd.browser_expand_all": "Perluas Semua", @@ -810,6 +827,7 @@ "cmd.browser_move": "Pindahkan Item Penjelajah", "cmd.browser_order": "Urutkan Item Penjelajah", "cmd.browser_rename_group": "Ganti Nama Grup", + "cmd.browser_reparent": "Pindah ke Komponen", "cmd.browser_ungroup": "Pisahkan Grup", "cmd.browser_visibility": "Tampil/Sembunyi", "cmd.canvas_calibrate": "Kalibrasi Kanvas", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Properti Part", "cmd.component_metadata_set": "Ubah Properti Part", "cmd.component_move_bodies": "Pindahkan Bodi ke Komponen", + "cmd.component_move_features": "Pindahkan Fitur ke Komponen", "cmd.component_move_sketches": "Pindahkan Sketsa ke Komponen", + "cmd.component_paste_from_design": "Tempel dari Desain Lain", "cmd.component_paste_new": "Tempel Baru", "cmd.component_properties": "Properti Fisik Komponen", "cmd.component_relink": "Tautkan Ulang Sumber", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Jejak Gerak", "cmd.motion_trace_edit": "Edit Jejak Gerakan", "cmd.motion_trace_keep": "Simpan Jejak Gerak", + "cmd.occurrence_align": "Sejajarkan Kemunculan", "cmd.occurrence_copy": "Tempel (Instans)", "cmd.occurrence_drag": "Seret Kemunculan", "cmd.occurrence_ground": "Ground", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Hapus Binding Permukaan", "cmd.render_library_targets": "Cari Target Pustaka Render", "cmd.render_lights_add": "Tambah Lampu", + "cmd.render_lights_duplicate": "Duplikasi Lampu Render", + "cmd.render_lights_list": "Daftar Lampu Render", + "cmd.render_lights_mute": "Bisukan Lampu", + "cmd.render_lights_remove": "Hapus Lampu Render", + "cmd.render_lights_set": "Atur Lampu Render", "cmd.render_lights_solo": "Lampu Solo", "cmd.render_optics_bind": "Simpan Binding Optik", "cmd.render_optics_unbind": "Hapus Binding Optik", "cmd.render_output_size": "Atur Ukuran Keluaran Render", "cmd.render_preflight": "Periksa Permintaan Render", + "cmd.render_preflight_copy": "Salin Laporan Pemeriksaan Render", "cmd.render_preset_apply": "Terapkan Studio Native", "cmd.render_preset_resolve": "Tinjau Studio Native", "cmd.render_quality_prepare": "Tinjau Anggaran Sampling Tetap", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Render Gambar", "cmd.render_status": "Progres Render", "cmd.select_clear": "Hapus Pilihan", + "cmd.settings_profile_apply": "Terapkan Profil Pengaturan", + "cmd.settings_profile_delete": "Hapus Profil Pengaturan", + "cmd.settings_profile_export": "Ekspor Profil Pengaturan", + "cmd.settings_profile_import": "Impor Profil Pengaturan", + "cmd.settings_profile_list": "Profil Pengaturan", + "cmd.settings_profile_rename": "Ganti Nama Profil Pengaturan", + "cmd.settings_profile_save": "Simpan Profil Pengaturan", "cmd.sheet_convert": "Konversi ke Pelat Logam", "cmd.sheet_export_bend_table": "Ekspor Tabel Tekukan", "cmd.sheet_export_dxf": "Ekspor Pola Datar sebagai DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Lipat Ulang", "cmd.sheet_rules": "Daftar Aturan Pelat Logam", "cmd.sheet_unfold": "Buka Lipatan", + "cmd.sketch.construction_line": "Garis bantu", + "cmd.sketch.construction_line.angle": "Garis bantu bersudut", + "cmd.sketch.construction_line.bisector": "Garis bagi sudut", + "cmd.sketch.construction_line.horizontal": "Garis bantu horizontal", + "cmd.sketch.construction_line.parallel": "Garis bantu sejajar", + "cmd.sketch.construction_line.perpendicular": "Garis bantu tegak lurus", + "cmd.sketch.construction_line.tangent_at": "Garis singgung pada titik kurva", + "cmd.sketch.construction_line.tangent_external": "Garis singgung melalui titik luar", + "cmd.sketch.construction_line.tangent_two": "Garis singgung persekutuan", + "cmd.sketch.construction_line.vertical": "Garis bantu vertikal", "cmd.sketch_arc3d": "Busur 3D", "cmd.sketch_arc_center_point": "Busur Titik Pusat", "cmd.sketch_arc_tangent": "Busur Tangen", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Ukuran Gambar", "cmd.sketch_edit": "Edit Sketsa", "cmd.sketch_edit_text": "Edit Teks", + "cmd.sketch_equation_curve": "Kurva Persamaan", + "cmd.sketch_equation_curve_edit": "Edit Kurva Persamaan", "cmd.sketch_export_dxf": "Simpan Sebagai DXF", "cmd.sketch_finish_auto_constrain": "Selesai dengan AutoConstrain", "cmd.sketch_fit_curves_to_section": "Fit Kurva ke Penampang Mesh", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "Penjelajah Render", "cmd.view.render_dock": "Pindah Dok Render", "cmd.view.render_focus": "Tampilan Fokus Render", + "cmd.view.status_items": "Item Status Viewport", "cmd.view_ambient_occlusion": "Ambient Occlusion", "cmd.view_angle_readout": "Sudut Tampilan", "cmd.view_apply": "Pulihkan Tampilan Bernama", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Tegak Lurus Pilihan", "cmd.view_previous_tab": "Tab Sebelumnya", "cmd.view_rename": "Ganti Nama Tampilan Bernama", + "cmd.view_render_frame": "Pemandu Bingkai Render", "cmd.view_right": "Tampilan Kanan", "cmd.view_save": "Tampilan Bernama Baru", "cmd.view_shots_strip": "Strip Bidikan", @@ -1229,10 +1277,14 @@ "color.copy": "Klik untuk menyalin nilai warna", "color.edit": "Klik untuk mengedit warna", "color.gamma": "Menampilkan nilai warna dalam ruang gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Masukkan #rrggbb atau #rrggbbaa; enam digit mempertahankan opasitas", + "color.hex_hint": "#rrggbb", "color.hue": "Rona", "color.linear": "Menampilkan nilai warna dalam ruang linear 0-1", "color.saturation_value": "Saturasi dan nilai", "color.selected": "Warna terpilih", + "config.column_filter_empty": "Tidak ada kolom yang cocok", "contact.components_count": "{count} komponen", "contact.enabled_label": "aktif", "coupling.gear_1": "Roda Gigi 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Klik dimensi penggerak lain untuk menyisipkan namanya", "dialog.dimension_name": "Dimensi: {name}", "dialog.edit_title": "EDIT {title}", + "dialog.no_shown_body": "Tidak ada bodi yang ditampilkan untuk diubah: tampilkan satu, atau pilih Bodi Baru", "dialog.select": "↖ Pilih", "dialog.selected_count": "↖ {count} terpilih", "dialog.selection_guidance": "Pilih input untuk mengubahnya. Klik objek terpilih untuk menghapusnya; Ctrl/Cmd-klik juga mengalihkannya. Tinjau pratinjau, lalu tekan OK.", @@ -1346,8 +1399,20 @@ "help.material.assign": "Menetapkan bahan pembuat bodi terpilih, seperti baja, aluminium, atau ABS. Gunakan agar massa dan bobot sesuai dengan part sebenarnya.", "help.mit": "Lisensi MIT", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Buat garis bantu horizontal tak hingga melalui sebuah titik. Batasan horizontalnya tetap berlaku pada pengeditan berikutnya.", + "help.sketch.construction_line.parallel": "Pilih sebuah garis sketsa lurus, lalu tempatkan garis bantu tak hingga yang sejajar. Klik kanan untuk memilih referensi lain.", + "help.sketch.construction_line.perpendicular": "Pilih sebuah garis sketsa lurus, lalu tempatkan garis bantu tak hingga yang tegak lurus. Klik kanan untuk memilih referensi lain.", + "help.sketch.construction_line.tangent_at": "Buat garis singgung pada sebuah titik di lingkaran atau busur lingkaran, atau pada titik ujung spline atau kurva konik yang terbuka. Pilih kurvanya, lalu titik singgungnya.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Hapus", "hole_presets.delete_prompt": "Hapus preset pribadi ini? Lubang yang sudah ada tidak berubah.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Part ini adalah bodi dari satu komponen: {bodies}. Sambungan menggerakkan satu komponen terhadap komponen lain, jadi setiap part memerlukan komponennya sendiri.", "joint.rigid_group_one_component": "Semua {count} bodi ini berada dalam satu komponen (atau di desain itu sendiri): gunakan {command} terlebih dahulu.", "joint.rigid_group_select_components": "Pilih dua komponen atau lebih.", + "joint_move.blocked_hint": "Periksa tipe sambungan (misalnya Kaku), status ground, batas sambungan, dan nilai yang digerakkan parameter.", + "joint_move.blocked_title": "Tidak dapat memindahkan part ini", + "joint_move.coarse": "Langkah rotasi dekat sumbu (°)", + "joint_move.distance": "Jarak dari awal", + "joint_move.done": "Selesai", + "joint_move.fine": "Langkah rotasi jauh dari sumbu (°)", + "joint_move.fixed": "Part ini tidak memiliki sumbu sambungan yang dapat digerakkan.", + "joint_move.help": "Pindah — seret panah atau cincin. Esc membatalkan seretan saat ini.", + "joint_move.legacy_hint": "Matikan untuk memakai panel Pindah sebelumnya.", + "joint_move.precision": "Tarik ke luar pemandu garis putus-putus untuk rotasi halus.", + "joint_move.rotation": "Rotasi dari awal", + "joint_move.select": "Pilih sebuah muka atau satu part rakitan, lalu pilih Pindah.", + "joint_move.settings": "Pindah di Rakitan", + "joint_move.step": "Langkah", + "joint_move.use_handles": "Gunakan handle gerak sambungan untuk Pindah di rakitan", "keymap.command_id": "ID Perintah", "keymap.empty": "Tidak ada perintah yang cocok", "keymap.use_anyway": "Tetap gunakan {key} di sini", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+seret kiri mengorbit, Alt+tengah menggeser, Alt+kanan memperbesar/memperkecil; seret tengah juga menggeser", "nav.openscad.help": "Seret kiri di ruang kosong memutar orbit, Shift+seret kiri memilih dengan kotak, seret geometri sketsa mengeditnya; seret kanan atau tombol tengah menggeser, Shift+seret kanan atau Shift+seret tombol tengah memperbesar/memperkecil", "nav.perspective.sketch": "Pengeditan sketsa memakai tampilan ortografis. Selesaikan sketsa untuk beralih ke perspektif.", + "nav.right_drag_orbits.help": "Mati: seret kanan tidak menggerakkan kamera dan tombol kanan hanya membuka menu konteks. Memilih pemetaan mouse SolidWorks akan mematikannya.", + "nav.right_drag_orbits.label": "Seret kanan untuk orbit", "nav.solidworks.help": "Seret tombol tengah memutar orbit, Ctrl+tombol tengah menggeser, Shift+tombol tengah memperbesar/memperkecil, seret kanan memutar orbit", "nav.tinkercad.help": "Seret kanan memutar orbit, seret tombol tengah atau Shift+kanan menggeser", "nav.touchpad.help": "Tahan Shift dan gerakkan penunjuk untuk pan; tahan Alt/Option dan gerakkan untuk orbit. Tidak perlu tombol mouse. Gulir vertikal untuk zoom.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Kustomisasi bilah navigasi…", "navbar.custom.design": "Desain", "navbar.custom.duplicate": "Slot ini sudah ada di bilah.", + "navbar.custom.edge": "Tepi", + "navbar.custom.edge_bottom": "Bawah", + "navbar.custom.edge_top": "Atas", "navbar.custom.fade": "Pudar", "navbar.custom.full": "Hapus satu slot untuk menambah yang lain.", "navbar.custom.hidden_design": "Disembunyikan di Desain", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Ketebalan", "parts.dimension.width": "Lebar", "parts.family.bearing": "Bearing bola alur dalam (selubung)", + "parts.family.button_head_cap_screw": "Sekrup kepala bulat (button head)", "parts.family.dowel_pin": "Pin paralel (dowel)", "parts.family.hex_bolt": "Baut / sekrup kepala segi enam", "parts.family.hex_nut": "Mur segi enam", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Menghaluskan tepi bergerigi.", "prefs.applies_on_restart": "Berlaku setelah dimulai ulang", "prefs.assistant": "Asisten", + "prefs.auto_operation": "Pilih operasi secara otomatis", + "prefs.auto_operation_note": "Jendela fitur memilih Gabung, Bodi Baru, atau Potong berdasarkan apa yang disentuh material baru, sampai Anda memilih salah satu. Mati: jendela mempertahankan operasi yang dipakai saat dibuka.", "prefs.axes": "Sumbu", "prefs.axis": "Sumbu", "prefs.bodies_folders": "Folder bodi", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Tengah bawah", "prefs.bottom_right": "Kanan bawah", "prefs.browser": "Penjelajah", + "prefs.browser_eye_left": "Ikon mata di sebelah panah perluas/ciutkan", + "prefs.browser_eye_left_note": "Baris penjelajah menampilkan ikon mata tampilkan/sembunyikan di sebelah panah perluas/ciutkan, seperti sebelumnya, bukan dalam satu kolom di ujung kanan baris.", + "prefs.browser_reveal_on_hover": "Tampilkan saat disorot", + "prefs.browser_show_note": "Ditampilkan menjaga penjelajah tetap di layar. Tampilkan saat disorot menyembunyikannya sampai penunjuk berada di atas tempatnya atau di atas tab tipis di tepi kirinya, dan penjelajah tetap terbuka selama penunjuk ada di dalamnya atau ada menu darinya yang terbuka.", + "prefs.browser_shown": "Ditampilkan", "prefs.built_in": "Bawaan", "prefs.button_left": "Seret kiri", "prefs.button_middle": "Seret tengah", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Berlaku setelah SolidCraft dimulai ulang. WGPU_ADAPTER_NAME menggantikan pilihan ini.", "prefs.graphics_note": "Tukar kecepatan dengan kehalusan. Setiap pilihan menampilkan efeknya pada sebuah contoh.", "prefs.graphics_presets_note": "Preset mengatur pilihan di bawah secara bersamaan.", + "prefs.hide_constraint_glyphs_zoomed_out": "Sembunyikan glif batasan saat zoom out", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Glif batasan menghilang dari tampilan saat sketsa di-zoom out cukup jauh sehingga glif itu membuat tampilan berantakan. Mati: glif tetap berukuran sama di layar pada setiap tingkat zoom.", "prefs.history": "Riwayat", "prefs.history_origin_bodies": "Tampilkan origin dan bodi di bagian atas", "prefs.history_origin_bodies_note": "Riwayat di sisi kiri atau di bawah penjelajah dimulai dengan folder Bodi komponen aktif dan Origin, lengkap dengan ikon mata dan menunya seperti di penjelajah. Riwayat di bagian bawah tidak berubah.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Ringkas menempelkan panel ke tepi jendela. Translusensi penjelajah tersedia di Mengambang. Ubah sisi Asisten di halaman Asisten.", "prefs.left": "Kiri", "prefs.left_click": "Klik kiri", + "prefs.line_drag_draws_one": "Tekan dan seret menggambar satu garis", + "prefs.line_drag_draws_one_note": "Di alat Garis, menekan, menyeret, lalu melepas menggambar satu garis dari titik tekan hingga titik lepas, dan garis berakhir di sana. Klik tetap menggambar rantai. Mati: seretan menaruh titik di tempat dilepas, seperti klik.", "prefs.linked_designs": "Desain tertaut", "prefs.live_input": "Input langsung", "prefs.live_preview": "Pratinjau langsung", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "File STEP dan impor", "prefs.max_file_size_web": "Aplikasi web membuka file STEP dan impor hingga 512 MB serta desain hingga 256 MB: tab peramban tidak dapat menampung file yang lebih besar. Aplikasi desktop dapat menaikkan batas ini.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Ikon besar", + "prefs.menu_list": "Daftar", + "prefs.menus": "Menu", "prefs.minutes": "menit", "prefs.minutes_suffix": " mnt", "prefs.mouse": "Mouse dan trackpad", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Penempatan", "prefs.prefer_free_sketch_points": "Utamakan titik sketsa yang dapat digerakkan saat titik saling tumpang tindih", "prefs.preview_choices": "Pratinjau pilihan Anda", + "prefs.print_face_highlight": "Sorotan permukaan cetak", + "prefs.print_face_highlight_tip": "Seberapa kuat permukaan cetak sebuah bodi diwarnai dan diberi garis tepi di tampilan. 0 % tidak menampilkan sorotan.", + "prefs.profile_active": "Profil aktif", + "prefs.profile_applied": "Profil diterapkan.", + "prefs.profile_changed": "Pengaturan berubah sejak profil disimpan. Simpan akan menyimpannya ke profil.", + "prefs.profile_deleted": "Profil dihapus.", + "prefs.profile_export": "Ekspor…", + "prefs.profile_exported": "Profil diekspor.", + "prefs.profile_imported": "Profil diimpor. Pilih di Profil aktif untuk beralih ke profil itu.", + "prefs.profile_name_hint": "Nama profil, mis. Laptop", + "prefs.profile_rename": "Ganti nama", + "prefs.profile_renamed": "Nama profil diganti.", + "prefs.profile_save": "Simpan", + "prefs.profile_saved": "Pengaturan saat ini disimpan ke profil.", + "prefs.profile_skipped": "Profil diterapkan; {n} pengaturan mempertahankan nilainya saat ini (lihat log).", + "prefs.profiles": "Profil pengaturan", + "prefs.profiles_note": "Pintasan, toolbar, navigasi, menu radial, dan pengaturan antarmuka, disimpan bersama di bawah satu nama dan diganti sekaligus.", + "prefs.project_options_dialog": "Dialog opsi Proyeksi", + "prefs.project_options_dialog_note": "Aktif: pilih Geometri atau Bodi dan Tautan Proyeksi sebelum memproyeksikan. Mati: pertahankan alur kerja pilih-langsung sebelumnya.", "prefs.quality_default": "default", "prefs.quality_fastest": "tercepat", "prefs.quality_slowest": "paling lambat", "prefs.quality_smoother": "lebih halus", + "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", + "prefs.ribbon_reveal": "Tampilkan hasil pencarian di ribbon", + "prefs.ribbon_reveal_note": "Mengarahkan kursor ke hasil di Cari perintah menunjukkan letak perintah itu di ribbon: tab-nya, tombolnya, atau barisnya di dropdown panel. Perintah yang dimulai dari pencarian berkedip di sana, dan perintah yang berjalan dari dropdown menandai panelnya.", "prefs.right_click": "Klik kanan", "prefs.saving": "Simpan otomatis dan pemulihan", "prefs.scale_note": "Juga Ctrl + / Ctrl − di jendela. Tombol berukuran 28 px pada 100%.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Tidak ada pengaturan yang cocok", "prefs.select": "Pilih", "prefs.select_one_item": "Pilih satu item", + "prefs.select_other_hidden": "Pilih geometri tersembunyi dengan Pilih Lainnya", + "prefs.select_other_hidden_help": "Aktif: sertakan bodi, sketsa, dan tepi tersembunyi di dekat penunjuk. Mati: pakai daftar asli geometri terlihat di bawah penunjuk.", "prefs.selecting": "Pemilihan", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift hanya menambah; Ctrl / Cmd mengalihkan", "prefs.shift_add_note": "Aktif: Shift menambah, Ctrl / Cmd mengalihkan. Nonaktif (default): keduanya mengalihkan.", "prefs.shortcuts": "Pintasan", + "prefs.single_surface_analysis": "Gunakan satu analisis permukaan", + "prefs.single_surface_analysis_help": "Saat mati, shading permukaan dan Kontinuitas dapat tetap aktif bersamaan. Mengaktifkan ini mempertahankan analisis terakhir yang dimulai atau diubah.", + "prefs.sketch_align_points": "Sejajarkan dengan titik sketsa lain saat menggambar", + "prefs.sketch_align_points_note": "Saat menggambar, sebuah titik sejajar secara horizontal dengan satu titik sketsa dan secara vertikal dengan titik lain sekaligus, dengan pemandu titik-titik, dan klik mempertahankan kedua perataan itu. Mati: hanya dengan titik sebelumnya, satu sumbu setiap kali.", "prefs.sketch_conflict_colour": "Geometri yang konflik dengan warna konflik", "prefs.sketch_conflict_colour_note": "Saat batasan sketsa saling konflik, kurva dan titik yang ditahan oleh batasan yang gagal juga digambar dengan warna konflik, bukan hanya glif atau dimensinya. Mati: warnanya tetap seperti biasa.", + "prefs.sketch_construction_lines": "Alat garis bantu lanjutan", "prefs.sketch_exit_view": "Saat sketsa selesai", "prefs.sketch_exit_view.keep": "Pertahankan tampilan saat ini", "prefs.sketch_exit_view.previous": "Kembali ke tampilan sebelumnya", "prefs.sketch_exit_view.unless_rotated": "Kembali kecuali diputar", "prefs.sketch_exit_view_note": "Ke mana tampilan beralih setelah Selesai Sketsa, Batal, atau Esc dua kali.", + "prefs.sketch_open_look_at": "Putar untuk menghadap sketsa saat dibuka", + "prefs.sketch_open_look_at_note": "Mati: membuat atau mengedit sketsa membiarkan tampilan apa adanya. Look At menghadapkan tampilan ke sketsa saat Anda menginginkannya.", + "prefs.sketch_recent_snap": "Snap otomatis ke pusat dan titik tengah terbaru", + "prefs.sketch_recent_snap_note": "Saat menggambar, kursor melakukan snap ke titik persilangan pemandu dari dua pusat lingkaran atau titik tengah tepi lurus terbaru. Mati: pemandu tersebut hanya bersifat visual dan kursor tidak bergerak.", "prefs.sketch_selection": "Sketsa & pilihan", "prefs.sketch_selection_heading": "Sketsa dan pilihan", "prefs.sketch_selection_note": "Cara sketsa melakukan snap dan cara klik memilih.", + "prefs.sketch_state_icons": "Ikon sketsa dengan warna statusnya", + "prefs.sketch_state_icons_note": "Di Riwayat dan penjelajah, ikon sketsa mengambil warna statusnya (kurang dibatasi, terbatasi penuh, atau bertentangan) dan tooltip-nya menyebut statusnya. Mati: setiap sketsa memakai ikon yang sama.", "prefs.sketch_tab_always": "Selalu tampilkan tab Sketsa", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Sumber", "prefs.speed": "Kecepatan", "prefs.stand_up_note": "Setelah file STEP atau IGES terbuka dengan Z ke atas, tawarkan seperempat putaran mengelilingi X.", + "prefs.stand_up_unknown": "Untuk file dari program yang tidak dikenal", + "prefs.stand_up_unknown.ask": "Tanya di dialog", + "prefs.stand_up_unknown.note": "Tampilkan catatan", + "prefs.stand_up_unknown.off": "Jangan tawarkan", + "prefs.stand_up_unknown_note": "Cara tawaran diberikan saat header file tidak menyebut program yang dikenal menyimpan dengan Y ke atas. Catatan hanya ditampilkan saat file tampak telentang; Tanya di dialog menanyakan setiap file semacam itu, seperti sebelumnya.", "prefs.status_bar": "Bilah status", "prefs.status_bar_note": "Bilah tipis di sepanjang tepi bawah jendela menampilkan apa yang sedang diedit, status sketsa yang terbuka dengan warnanya, dan satuan desain; klik pada satuan akan mengubahnya. Status sketsa lalu tidak lagi tampil di sudut tampilan.", "prefs.status_done": "Selesai", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Pemberitahuan", "prefs.toggle_item": "Tambahkan item, atau keluarkan lagi", "prefs.toolbar": "Toolbar", + "prefs.toolbar_tabs": "Tab toolbar", "prefs.top": "Atas", + "prefs.trackpad_zoom_modifier": "Zoom dengan gulir dua jari sambil menahan", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Tahan hanya tombol ini untuk zoom di penunjuk dengan gulir vertikal. Mati mempertahankan perilaku gulir yang ada.", + "prefs.trackpad_zoom_modifier.off": "Mati", "prefs.translucent": "Tembus pandang", "prefs.under_browser": "Di bawah penjelajah", "prefs.under_cursor": "Di bawah kursor", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Viewport", + "prefs.viewport_analysis_status": "Tampilkan status analisis viewport", + "prefs.viewport_analysis_status_help": "Tampilkan analisis yang aktif di atas viewport, buka legendanya dari setiap item, dan gunakan Esc untuk mematikan analisis terbaru. Matikan untuk mengembalikan legenda di sudut dan perilaku Esc sebelumnya.", + "prefs.viewport_background": "Latar belakang", + "prefs.viewport_background_colour": "Warna", + "prefs.viewport_background_theme": "Gradasi tema", "prefs.wheel": "Roda", "prefs.wheel_trackpad": "Roda dan trackpad", "prefs.workspace": "Workspace", + "prefs.zoom_stop_at_model": "Hentikan zoom di model", + "prefs.zoom_stop_at_model_tip": "Mouse 3D dan aplikasi perangkat melakukan zoom ke arah permukaan di tengah tampilan; zoom seret menahan titik tempat seretan dimulai. Mati: zoom dapat menembus model. Roda dan cubit memakai Zoom ke kursor secara terpisah", "prefs.zoom_toward_cursor": "Zoom, ke arah kursor", "properties.title": "Properti: {name}", "recovery.days_ago": "{count} hari lalu", "recovery.detail": "{file} · {features} fitur · disimpan otomatis {when}", + "recovery.discard_all": "Buang semua", + "recovery.discard_all_detail": "Aksi ini tidak dapat diurungkan.", + "recovery.discard_all_title": "Buang {count} simpan otomatis?", + "recovery.discard_all_title_one": "Buang 1 simpan otomatis?", "recovery.hours_ago": "{count} jam lalu", "recovery.just_now": "baru saja", "recovery.min_ago": "{count} mnt lalu", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Bobot", "render.appearance_physics_separate": "Tampilan visual tidak menetapkan massa jenis, data termal, atau properti rekayasa lainnya.", "render.appearances": "Tampilan", + "render.assign_help": "Klik untuk menerapkan ke pilihan; seret untuk pratinjau lalu lepaskan, atau klik tanpa ada yang dipilih untuk mengecat.", "render.availability_native": "Komposisi native; tinjau sebelum diterapkan", "render.availability_projection": "Proyeksi yang didukung; lihat cakupan dan bagian yang dihilangkan", "render.availability_reference": "Data referensi; bukan penetapan otomatis", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Ambang sorotan linear", "render.browse_capabilities": "Periksa katalog kemampuan lengkap", "render.browse_environments": "Jelajahi lingkungan HDR orisinal", + "render.browse_looks": "Jelajahi…", "render.browse_native_scenes": "Jelajahi studio native", "render.browse_physical": "Periksa referensi fisik", "render.browse_quality": "Periksa spesifikasi kualitas", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Transparansi: hanya permukaan opak; alfa latar belakang didukung.", "render.check_sorted_alpha": "Transparansi: perkiraan alfa terurut.", "render.check_transmission": "Transparansi: transmisi fisik.", + "render.children.absolute_floor": "Batas bawah absolut", + "render.children.adaptive": "Adaptif", + "render.children.add_look_reason": "Tambahkan look untuk mengedit status aktif dan kekuatannya.", + "render.children.all_shots": "Semua bidikan", + "render.children.bloom": "Bloom", + "render.children.check": "Periksa", + "render.children.check_now": "Periksa sekarang", + "render.children.chromatic": "Kromatik", + "render.children.compact": "Ringkas", + "render.children.contrast": "Kontras", + "render.children.copy_image": "Salin", + "render.children.copy_job": "Salin JSON tugas", + "render.children.copy_request": "Salin JSON permintaan", + "render.children.coverage_error": "Galat cakupan", + "render.children.cpu": "Ray tracing CPU", + "render.children.denoise": "Kurangi noise pada gambar jadi", + "render.children.depth": "Kedalaman jalur", + "render.children.distortion": "Distorsi", + "render.children.edge_samples": "Sampel tepi", + "render.children.effects_banner": "Efek ini memerlukan path tracing GPU. Membuka bagian ini tidak pernah mengubah metode.", + "render.children.enable_adaptive": "Aktifkan pilot independen untuk mengedit batas adaptif.", + "render.children.enable_bloom": "Aktifkan bloom untuk mengedit kekuatan, ambang, dan radiusnya.", + "render.children.finishing": "Finishing", + "render.children.flare": "Flare", + "render.children.fresnel": "Strata Fresnel", + "render.children.ghosts": "Cincin ghost", + "render.children.glass": "Transport kaca", + "render.children.go_to": "Buka {editor}", + "render.children.grain": "Grain", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Simpan master linear mentah", + "render.children.last_result": "Terakhir: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efek lensa", + "render.children.look_summary": "{tone} · {count} look", + "render.children.looks": "Look kreatif", + "render.children.memory": "Memori", + "render.children.method": "Metode", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Memerlukan ray tracing CPU. Membuka bagian ini tidak pernah mengubah metode.", + "render.children.nested": "Dielektrik bersarang tersertifikasi", + "render.children.not_checked": "Belum diperiksa", + "render.children.path": "Path tracing", + "render.children.pilots": "Sampel pilot", + "render.children.preview": "Payload pratinjau", + "render.children.progressive_reason": "Pilih Progresif untuk mengedit jumlah sampel dan seed-nya.", + "render.children.radius": "Radius", + "render.children.reflect_depth": "Kedalaman pantulan", + "render.children.reflect_samples": "Sampel pantulan", + "render.children.regrade": "Koreksi warna ulang", + "render.children.relative_error": "Galat relatif", + "render.children.render": "Render", + "render.children.render_shot": "Render bidikan", + "render.children.renderer": "Renderer", + "render.children.renderers": "Renderer", + "render.children.request": "Permintaan ini", + "render.children.reset_finishing": "Atur ulang finishing", + "render.children.review_quality": "Tinjau spesifikasi kualitas…", + "render.children.rgb_gain": "Gain RGB", + "render.children.samples": "Sampel", + "render.children.sampling": "Sampling", + "render.children.saturation": "Saturasi", + "render.children.scene_summary": "{bodies} bodi · {triangles} segitiga", + "render.children.seed": "Seed", + "render.children.sharpening": "Penajaman", + "render.children.single_image": "Gambar tunggal", + "render.children.soft_focus": "Fokus lembut", + "render.children.streak": "Streak", + "render.children.strength": "Kekuatan", + "render.children.studio": "Studio", + "render.children.threshold": "Ambang", + "render.children.tone": "Tone", + "render.children.under_hood": "Di balik layar", + "render.children.vignette": "Vinyet", + "render.children.workstation": "Workstation", + "render.choose_look": "Pilih look di Tampilan terlebih dahulu", "render.chromatic_offset": "Offset tepi kromatik", "render.clear_stage": "Hapus panggung tersimpan", "render.copy_request": "Salin JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Kekuatan flare sorotan", "render.flare_threshold": "Ambang flare linear-adegan", "render.fold_browser": "Lipat Penjelajah", + "render.frame_aspect": "Aspek", + "render.frame_centre_mark": "Tanda pusat", + "render.frame_copy_limit": "Tidak ada nama bidikan duplikat yang tersedia", + "render.frame_copy_name": "{name} salinan {number}", + "render.frame_custom": "Kustom", + "render.frame_distance_unit": " × ukuran", + "render.frame_duplicate": "Duplikasi", + "render.frame_exposure": "Eksposur", + "render.frame_fov": "Bidang pandang", + "render.frame_guides": "Pemandu", + "render.frame_invalid_aspect": "Preset aspek tidak valid", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Nama", + "render.frame_output_size": "Ukuran keluaran", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Potret", "render.frame_presets": "Preset frame", "render.frame_presets_pending": "Hadir bersama editor Bidikan", + "render.frame_projection": "Proyeksi", + "render.frame_render_shot": "Render bidikan ini", + "render.frame_rule_thirds": "Aturan sepertiga", + "render.frame_safe_area": "Area aman", + "render.frame_shot_title": "Bidikan · {name}", + "render.frame_square": "1:1 · Persegi", + "render.frame_tall": "9:16 · Vertikal", + "render.frame_thirds": "Sepertiga", + "render.frame_wide": "16:9 · Layar lebar", "render.gpu_depth": "Kedalaman jalur maksimum", "render.gpu_options_help": "Sampel, kedalaman, seed, dan eksposur disimpan bersama setup ini. Hanya metode gambar yang aktif yang disimpan.", "render.gpu_path_failed": "GPU path tracer tidak tersedia", @@ -2360,6 +2627,7 @@ "render.heading_results": "HASIL", "render.heading_shots": "BIDIKAN", "render.hide_shots_label": "Sembunyikan strip Bidikan", + "render.hover_look": "{object} · {look} · klik untuk memilih", "render.image_advanced": "Kontrol GPU lanjutan", "render.image_cancelled": "Render dibatalkan", "render.image_cancelling": "Membatalkan", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Gunakan aproksimasi seragam tanpa efek spasial atau lanjutan", "render.library_usable": "Proyeksi yang dapat dieksekusi", "render.library_users": "Dipakai oleh", + "render.light_aim": "Arahkan ke", "render.light_anchor_pending": "Mengubah kerangka koordinat lampu belum didukung.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Pilih ray tracing CPU atau studio GPU eksplisit untuk mengedit lampu. Lampu studio GPU lama tetap hanya-baca.", + "render.light_behaviour": "PERILAKU", + "render.light_default_fill": "Fill", + "render.light_default_key": "Key", + "render.light_default_number": "Lampu {number}", + "render.light_default_rim": "Rim", + "render.light_delete": "Hapus", + "render.light_distance": "Jarak", + "render.light_duplicate": "Duplikasi", + "render.light_elevation": "Elevasi", + "render.light_gpu_punctual": "Lampu Titik dan Spot tersedia dengan ray tracing CPU.", + "render.light_hard_shadow": "Lampu ini memiliki bayangan tajam; kelembutan emitter tidak tersedia.", + "render.light_intensity": "Intensitas", + "render.light_missing": "Lampu ini sudah tidak ada. Pilih lampu di Penjelajah.", + "render.light_place_highlight": "Tempatkan menurut sorotan", + "render.light_place_pending": "Penempatan menurut sorotan memerlukan pemilihan lampu di viewport dan belum didukung.", + "render.light_position": "POSISI", + "render.light_preview_help": "Tampilan langsung tanpa bayangan; lampu Area memakai perkiraan sampel pusat. Render memakai model cahaya yang lengkap.", + "render.light_product_centre": "Pusat produk", + "render.light_size": "Ukuran", + "render.light_softness": "Kelembutan", + "render.light_temperature": "Suhu warna", + "render.light_temperature_help": "Perkiraan rona benda hitam; disimpan sebagai RGB linear.", + "render.light_type": "Tipe", + "render.light_type_area": "Area", + "render.light_type_point": "Titik", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Matahari", + "render.light_view_only": "Solo dan bisu memengaruhi tampilan ini dan render-nya; keduanya tidak pernah disimpan dalam desain.", + "render.light_world_origin": "Origin dunia", + "render.lighting_aim_point": "Titik bidik", + "render.lighting_apply": "Terapkan preset", + "render.lighting_area_samples": "Sampel area", + "render.lighting_budget": "Anggaran", + "render.lighting_budget_help": "Jumlahnya hanya-baca. Tambah, duplikasi, atau hapus lampu untuk mengubahnya; metode menentukan batasnya.", + "render.lighting_colour": "Warna", + "render.lighting_consent": "Ganti lampu arah dengan preset ini", + "render.lighting_custom": "Lampu kustom", + "render.lighting_custom_help": "Memilih Kustom mengubah preset menjadi lampu yang dapat diedit, masing-masing satu baris di pohon.", + "render.lighting_direction": "Arah X Y Z", + "render.lighting_direction_help": "Atau ketik arah menuju lampu (X Y Z).", + "render.lighting_distance_unit": " × ukuran", + "render.lighting_edge_u": "Vektor sisi U", + "render.lighting_edge_v": "Vektor sisi V", + "render.lighting_falloff": "Atenuasi", + "render.lighting_falloff_help": "Intensitas dinyatakan pada jarak referensi.", + "render.lighting_height": "Tinggi V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Bisukan lampu ini", + "render.lighting_point": "Titik", + "render.lighting_preset": "Preset studio", + "render.lighting_preset_cpu": "Preset studio arah memerlukan ray tracing CPU. Pencahayaan GPU memakai lampu Matahari dan Area yang eksplisit.", + "render.lighting_preset_row": "Preset", + "render.lighting_radiance": "Radiance", + "render.lighting_reference": "Referensi", + "render.lighting_review_help": "Mengganti daftar lampu arah. Mempertahankan lampu titik dan area, lingkungan, bayangan, kamera, dan semua pengaturan bidikan lainnya.", + "render.lighting_review_title": "Tinjau preset pencahayaan", + "render.lighting_samples_cpu": "Sampel lampu area berlaku untuk ray tracing CPU.", + "render.lighting_samples_need_area": "Tambahkan lampu area CPU untuk mengatur sampel area.", + "render.lighting_samples_reason": "Sampel area harus 1, 4, 16 atau 64.", + "render.lighting_shadow_cpu": "Sakelar bayangan memerlukan ray tracing CPU. Path tracing GPU selalu menghasilkan bayangan.", + "render.lighting_shadows": "Buat bayangan", + "render.lighting_shadows_section": "Bayangan dan sampling", + "render.lighting_shape": "Bentuk", + "render.lighting_solo": "Jadikan lampu ini solo", + "render.lighting_source": "Sumber", + "render.lighting_source_section": "Sumber cahaya", + "render.lighting_spot_preserved": "Lampu titik ini mempertahankan kerucut spot tersimpannya sampai tipenya diubah.", + "render.lighting_sun_distance": "Arah matahari tidak bergantung pada jarak dan titik bidik.", + "render.lighting_unsupported": "tidak didukung", + "render.lighting_used": "{used} dari {limit}", + "render.lighting_width": "Lebar U", + "render.lighting_world_position": "Dunia X Y Z", "render.lights_fixed_to": "Lampu tetap pada", "render.linear_help": "Mempertahankan master RGBA float 32-bit sebelum denoising, eksposur, tone mapping, filter, dan latar belakang. sRGB linear/D65; premultiplied terhadap cakupan. Memori tambahan diterima secara eksplisit. Tanpa kompresi atau pass tambahan.", "render.linear_retain": "Simpan EXR linear mentah", "render.linear_unavailable": "Bingkai selesai yang dipilih tidak memiliki EXR yang disimpan. Aktifkan penyimpanan sebelum merender; PNG lama tidak dapat memulihkan radiance mentah.", "render.look_add": "Tambah look warna", + "render.look_applied": "{name} diterapkan", "render.look_bleach": "Terinspirasi bleach-bypass", "render.look_cool": "Dingin", "render.look_cyanotype": "Terinspirasi sianotipe", "render.look_faded": "Cetakan pudar", + "render.look_material": "Material", "render.look_mono": "Monokrom", + "render.look_pasted": "Look ditempel", "render.look_sepia": "Sepia", "render.look_strength": "Kekuatan look", "render.look_teal_orange": "Teal / oranye", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Lanjutan", "render.output_backend_ready": "Siap", "render.output_check": "Periksa", + "render.output_check_failed": "Periksa · galat", "render.output_check_help": "Jalankan Periksa untuk meninjau permintaan ini tanpa memulai render.", "render.output_check_summary": "Periksa · Catatan: {count}", "render.output_copy": "Salin", + "render.output_copy_report": "Salin laporan", "render.output_destination": "Tujuan", "render.output_destination_help": "Pilih file tujuan saat mengekspor gambar yang sudah selesai.", "render.output_dimensions": "Ukuran", @@ -2604,6 +2952,7 @@ "render.output_fit": "Pas", "render.output_format": "Format", "render.output_format_status": "PNG adalah gambar tampilan selesai yang persis. EXR GPU opsional menyimpan radiance FLOAT32 linear-adegan mentah sebelum filter tampilan. Ekspor tidak pernah menggantikan cache pratinjau atau pekerjaan lain.", + "render.output_grading": "Koreksi warna", "render.output_image": "Gambar", "render.output_largest": "{width} × {height} maks", "render.output_lock_ratio": "Kunci rasio aspek", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "menunggu kontrak kualitas", "render.output_ratio_refused": "Dimensi yang tertaut melebihi batas ukuran metode ini. Ukuran sebelumnya dipertahankan; buka kunci rasio atau pilih nilai yang lebih kecil.", "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_ok": "Ukuran OK · {width} × {height} px", "render.output_swap_orientation": "Tukar lebar dan tinggi", "render.output_tools_inspector": "Edit ukuran dan format di Inspektor Keluaran.", @@ -2629,6 +2980,7 @@ "render.page_output": "Keluaran", "render.page_scene": "Adegan", "render.paint_label": "Cat", + "render.painting_with": "Mengecat dengan {name}", "render.passive_stage": "Lantai dan panggung pasif", "render.passive_stage_help": "Panel pasif berhingga hanya milik gambar yang dirender. Panel ini tidak menambah bodi CAD atau massa. Penempatan menjadi tetap setelah diterapkan; batas produk yang berpotongan ditolak. CPU tidak menyediakan pencahayaan pantulan difus.", "render.perspective": "Kamera perspektif", @@ -2650,6 +3002,7 @@ "render.pick_help": "Pilih Bodi, Muka, atau Komponen. Klik ruang kosong untuk membersihkan; Shift atau Ctrl menambah atau mengalihkan. Sorotan hanya muncul di tampilan produk langsung.", "render.pick_label": "Pilih", "render.pick_up_label": "Ambil", + "render.pick_up_prompt": "Klik sebuah objek untuk mengambil look-nya", "render.pick_waiting": "Pemilihan produk belum didukung.", "render.post_contrast": "Kontras", "render.post_gain_b": "Gain biru", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Distorsi radial", "render.radial_fit": "Fit Produk", "render.radial_save": "Simpan Bidikan", + "render.recent_looks": "Look terbaru", "render.regrade_action": "Koreksi warna ulang master mentah (tanpa denoise)", "render.regrade_help": "Membuat PNG terpisah dari master mentah selesai yang dipilih, memakai kontrol presentasi ini. Tidak menelusuri sinar, tidak mengubah ukuran/kamera/lampu yang ditangkap, dan tidak menimpa EXR. Denoise tidak diterapkan lagi: master mentah tidak menyimpan panduannya. Turunan ini tetap dapat diekspor melalui kontrol tugas yang sama.", "render.regrade_label": "Koreksi warna ulang", "render.reload_settings": "Muat Ulang Pengaturan Tersimpan", + "render.remove_override": "Hapus Override", + "render.replace_all": "Ganti Semua", "render.replace_ground": "Konfirmasi penggantian panggung yang ada", "render.replace_label": "Ganti", "render.replace_saved_surface": "Ganti binding tersimpan yang ada untuk target ini", + "render.replace_with": "Ganti dengan", "render.restore_camera": "Pulihkan Kamera Tersimpan", "render.result_actual": "100%", "render.result_actual_help": "Satu piksel gambar per piksel layar.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Hapus binding tersimpan", "render.saved_surfaces": "Binding permukaan tersimpan", "render.saved_surfaces_help": "Peta bawaan yang disematkan mengikuti kemunculan terpilih dalam milimeter lokal. Target yang diganti atau hilang adalah galat, bukan penugasan ulang otomatis.", + "render.scene_all_shots": "Semua bidikan ({count})", "render.scene_apply": "Terapkan studio yang sudah ditinjau", + "render.scene_batch_reason": "Semua bidikan memerlukan antrean render bidikan tersimpan, yang belum tersedia.", + "render.scene_check_now": "Periksa sekarang", + "render.scene_check_passed": "Pemeriksaan lolos · {notes} catatan", + "render.scene_check_stale": "Pemeriksaan usang · periksa lagi", + "render.scene_check_unchecked": "Belum diperiksa · 0 catatan", "render.scene_consent": "Konfirmasi penggantian pencahayaan, lingkungan, eksposur, dan panggung", + "render.scene_light_summary": "{environment} · {lights} lampu", + "render.scene_look_summary": "{looks} look · {overrides} override", + "render.scene_open_check": "Buka Pemeriksaan", "render.scene_preserved_fields": "Kamera, sampling, dimensi, binding optik, geometri CAD, dan material rekayasa tetap dipertahankan.", "render.scene_preserves_design": "Susun pencahayaan dan panggung pasif untuk subjek yang terlihat. Geometri, material rekayasa, dan kamera tetap terpisah.", + "render.scene_ready": "Siap dirender", + "render.scene_reminder": "Adegan mempertahankan desain. Geometri, properti rekayasa, dan CAD tidak pernah diubah dari Render.", + "render.scene_reminders": "Pengingat", + "render.scene_renderer_ready": "{renderer} siap", "render.scene_replaces": "Mengganti", "render.scene_review": "Proses dan tinjau", "render.scene_review_help": "Tinjau komposisi native secara lengkap sebelum menerapkan. Spesifikasi SCENE lanjutan yang tidak terikat tidak digantikan.", "render.scene_scope_details": "Cakupan komposisi", + "render.scene_stages": "Tahapan", "render.search_appearances": "Cari di {count} tampilan", "render.setup_legacy_environment": "Setup lama ini memakai rotasi atau intensitas lingkungan versi lama. Nilai tersebut dipertahankan; konversikan secara eksplisit sebelum menyimpan tugas gambar penuh.", "render.setup_name": "Nama setup", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "KAMERA RENDER", "render.shell_tab_lighting": "PENCAHAYAAN", "render.shell_tab_scene": "ADEGAN RENDER", + "render.shot_aperture": "Apertur", + "render.shot_distance": "Jarak", + "render.shot_dof": "Kedalaman bidang", "render.shot_duplicate": "Duplikasi bidikan tersimpan", + "render.shot_edit_output": "Edit di Keluaran", + "render.shot_exposure_reason": "Eksposur memerlukan ray tracing CPU atau path tracing GPU.", + "render.shot_exposure_section": "Eksposur", + "render.shot_focus_distance": "Jarak fokus", + "render.shot_fov_reason": "Bidang pandang memerlukan proyeksi perspektif.", + "render.shot_frame_subject": "Bingkaikan subjek", + "render.shot_invalid_camera": "Nilai kamera harus berhingga dan dalam rentang.", + "render.shot_lens": "Lensa", + "render.shot_lens_enable_reason": "Aktifkan kedalaman bidang untuk mengedit apertur dan jarak fokus.", + "render.shot_lens_gpu_reason": "Kedalaman bidang memerlukan path tracing GPU.", + "render.shot_lens_perspective_reason": "Kedalaman bidang memerlukan proyeksi perspektif.", + "render.shot_look_selection": "Hadapkan ke pilihan", "render.shot_manager": "Bidikan tersimpan", "render.shot_new_name": "Nama bidikan baru", "render.shot_remove": "Hapus bidikan tersimpan", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Hapus bidikan tersimpan?", "render.shot_rename": "Ganti nama bidikan tersimpan", "render.shot_saved_only": "Simpan draf saat ini terlebih dahulu. Duplikasi dan ganti nama mempertahankan opsi tersimpan dan referensi sumber daya secara persis.", + "render.shot_selection_reason": "Pilih bodi atau muka sebagai sasaran.", + "render.shot_size": "Ukuran", + "render.shot_target": "Target", "render.shots_camera_changed": "Kamera berubah sejak bidikan ini disimpan", "render.shots_name_help": "Masukkan nama unik untuk bidikan saat ini.", "render.shots_no_result": "Belum ada render", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "belum didukung: pemilihan produk dan pengambilan sampel tampilan", "render.wait_replace": "belum didukung: penggantian semua pemakaian suatu tampilan", "render.wait_solo": "belum didukung: visibilitas lampu dan kontrol solo", + "render.wear_applies_to": "Berlaku untuk", + "render.wear_layer_count": "{count} lapisan", + "render.wear_layer_one": "{count} lapisan", + "render.wear_layers": "Lapisan", + "render.wear_no_layers": "Tidak ada lapisan", + "render.wear_selected_layer": "Lapisan terpilih", + "render.wear_title": "Keausan", "sample.bracket.about": "Flens dasar dengan flens tepi dan hem pada aturan 2 mm", "sample.bracket.name": "Braket Pelat Logam", "sample.enclosure.about": "Kotak shell dengan lip, boss sekrup, rest, dan snap fit", @@ -3030,6 +3426,7 @@ "select an axis": "pilih sumbu", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "pilih sumbu, tepi lurus atau melingkar, atau muka silinder, kerucut, atau torus terlebih dahulu", "select bodies first": "pilih bodi terlebih dahulu", + "select components first": "pilih komponen terlebih dahulu", "select corner points, or the two lines of one corner": "pilih titik sudut, atau dua garis dari satu sudut", "select one corner point, or the two lines of one corner": "pilih satu titik sudut, atau dua garis dari satu sudut", "select sheet edges first": "pilih tepi pelat terlebih dahulu", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Klik dua kali komponen untuk mengaktifkannya, atau untuk mengedit komponen tertaut di tempat", "settings.frame_rate_details_note": "Menambahkan grafik waktu frame terakhir dengan segitiga yang digambar di atasnya, histogram waktu frame, dan apa yang digambar GPU. Klik frame rate di tampilan untuk menampilkan atau menyembunyikannya.", "settings.frame_rate_note": "Menampilkan frame per detik di sudut kanan bawah tampilan.", + "settings.insert_lists_open_designs": "Sisipkan Desain mencantumkan desain yang terbuka di tab lain terlebih dahulu", "settings.measure_axis_distances": "Tampilkan jarak X/Y/Z", "settings.measure_axis_distances_help": "Tampilkan dimensi komponen sumbu dunia di samping jarak total saat Ukur terbuka.", "settings.msaa_applies_on_restart": "Anti-aliasing berlaku saat SolidCraft dimulai berikutnya", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Lepas sematan", "sketch is missing": "sketsa tidak ditemukan", "sketch.degrees_of_freedom": "{count} derajat kebebasan", + "sketch.dimension_added_driven": "Sketsa sudah menentukan dimensi itu, sehingga dimensi ditambahkan sebagai dimensi driven (referensi)", + "sketch.dimension_applied": "Dimensi diterapkan", + "sketch.dimension_applied_defined": "Dimensi diterapkan; parameter `{name}` didefinisikan", + "sketch.dimension_applied_renamed": "Dimensi diterapkan; parameter diberi nama `{name}`", "sketch.dof_left": "{count} derajat kebebasan tersisa", "sketch.dof_left_one": "{count} derajat kebebasan tersisa", "sketch.draw_curves": "Gambar sebagai {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Gunakan file font {path}", "solid": "solid", "status.editing": "Mengedit {name}", + "status.navigation": "Pemetaan mouse: {mapping}", + "status.navigation_tip": "Cara mouse menggerakkan tampilan. Klik untuk memilih pemetaan lain", "status.under_constrained": "Kurang dibatasi ({count} derajat kebebasan)", "status.under_constrained_one": "Kurang dibatasi ({count} derajat kebebasan)", "status.units_tip": "Satuan desain. Klik untuk mengubahnya", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Pertahankan fitur dan folder bersarangnya di riwayat", "timeline.delete_folders_with_features_tip": "Hapus folder beserta setiap fitur di dalamnya dan folder bersarangnya", "timeline.delete_many": "Hapus {count} Fitur Terpilih", + "timeline.edited_rolled_back": "{name} diubah, tetapi berada setelah penanda riwayat, sehingga tidak dibangun: pindahkan penanda melewatinya untuk melihat hasilnya", "timeline.feature_error": "Error: {message}", "timeline.feature_hint": "Klik dua kali untuk edit · seret untuk mengurutkan ulang · klik kanan untuk lainnya", + "timeline.feature_rolled_back": "Dimundurkan: berada setelah penanda riwayat, sehingga tidak dibangun", "timeline.feature_warning": "Peringatan: {message}", "timeline.group": "Folder Fitur Baru", "timeline.move_many": "Pindahkan Item Linimasa", "timeline.move_to_group": "Pindahkan Fitur ke Folder", "timeline.rename_group": "Ganti Nama Folder Fitur", "timeline.reorder_many": "Urutkan Ulang Fitur", + "timeline.suppress_features": "Tekan Fitur", "timeline.suppress_folders_tip": "Tekan atau lepas penekanan setiap fitur di folder dan folder bersarangnya", "timeline.ungroup": "Hapus Folder Fitur", + "timeline.unsuppress_features": "Lepas Tekan Fitur", "to the face": "ke muka", "to the next face": "ke muka berikutnya", "toolbar.customize_note": "Tab {tab}. Centang perintah untuk memberinya tombol toolbar. Anda juga dapat menyeret baris dropdown panel ke toolbar, menyeret tombol keluar dari toolbar untuk menghapusnya, atau memakai pin pada baris dropdown.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Mengkalibrasi mouse 3D memperbaiki sumbu yang tertukar atau terbalik dan mengatur kecepatan serta rasanya.", "ui.3d_mouse_offer_never": "Jangan tampilkan lagi", "ui.3d_mouse_offer_swap": "Beralih ke Hidraw (driver langsung)", + "ui.3d_mouse_orbit": "Orbit", + "ui.3d_mouse_orbit_classic": "Klasik", + "ui.3d_mouse_orbit_tip": "Terlacak: mouse 3D memutar tampilan mengelilingi bagian model yang ada di tengah tampilan saat putaran dimulai, ditandai dengan bola samar. Klasik: mengelilingi pusat tampilan", + "ui.3d_mouse_orbit_traced": "Terlacak", "ui.3d_mouse_reading": "Pembacaan", "ui.3d_mouse_reset_axes": "Atur ulang penetapan sumbu", "ui.3d_mouse_reset_view": "Atur ulang tampilan", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Putar cap berlawanan arah jarum jam, tahan, lalu lepaskan.", "ui.3d_mouse_switch_cap": "Kalibrasi sebagai Kembali ke Tengah", "ui.3d_mouse_switch_trackball": "Kalibrasi sebagai trackball", + "ui.3d_mouse_use_direct": "Gunakan driver langsung", + "ui.3d_mouse_use_direct_tip": "Baca mouse 3D secara langsung (hidraw), bukan melalui spacenavd.", "ui.3d_mouse_view_axis": "Sumbu tampilan", "ui.3d_mouse_view_off": "Mati", "ui.3d_mouse_view_off_tip": "Profil driver mouse 3D mematikan sumbu ini, sehingga gerakannya tidak menggerakkan tampilan. Memilih gerakan sumbu lain di sini akan menukar keduanya, dan gerakan itu berhenti menggerakkan tampilan.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Batal", "ui.canvases": "Kanvas", "ui.center": "Pusat", + "ui.center_arc_follows_cursor": "Busur titik pusat mengikuti arah kursor", + "ui.center_arc_follows_cursor_tip": "Busur titik pusat berjalan searah atau berlawanan arah jarum jam sesuai arah kursor mengitari pusatnya. Mati: selalu berlawanan arah jarum jam dari awal.", "ui.centerline": "Garis tengah", "ui.chain": "Rantai", "ui.chamfer": "Chamfer", @@ -3570,6 +3986,7 @@ "ui.circle": "Lingkaran", "ui.circular_pattern": "Pola Melingkar", "ui.clear": "Bersihkan", + "ui.clear_print_face": "Hapus permukaan cetak", "ui.clear_recent": "Bersihkan Terbaru", "ui.clear_the_selection": "Hapus pilihan", "ui.click_selects_whole_bodies": "Klik memilih seluruh bodi", @@ -3604,6 +4021,15 @@ "ui.constant": "Konstan", "ui.constraints_conflict": "Batasan bertentangan", "ui.construction": "Konstruksi", + "ui.construction.angle_hint": "Garis bantu: klik sebuah titik, lalu ketik sudut atau pilih arah", + "ui.construction.bisector_hint": "Garis bagi sudut: pilih dua garis lurus yang berpotongan, lalu solusi yang diinginkan", + "ui.construction.common_hint": "Garis singgung persekutuan: pilih dua lingkaran/busur, lalu solusi yang diinginkan", + "ui.construction.contact_hint": "Garis singgung: pilih lingkaran/busur atau spline/kurva konik terbuka, lalu titik singgung (kurva bentuk bebas: titik ujung)", + "ui.construction.external_hint": "Garis singgung: pilih lingkaran atau busur, sebuah titik luar, lalu solusi yang diinginkan", + "ui.construction.free_hint": "Garis bantu: klik dua titik; klik kanan untuk mengulang", + "ui.construction.point_hint": "Garis bantu: klik titik yang akan dilalui", + "ui.construction.reference_hint": "Garis bantu: pilih garis lurus, lalu sebuah titik; klik kanan untuk mengganti referensi", + "ui.construction.ribbon_label": "Garis bantu", "ui.context_menu": "Menu konteks", "ui.continuity_g0": "Lipatan G0", "ui.continuity_g1": "Tangen G1", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Skala Akhir", "ui.entities": "Entitas", "ui.equal": "Sama", + "ui.equation_end": "Akhir", + "ui.equation_explicit": "Eksplisit", + "ui.equation_form": "Bentuk", + "ui.equation_note": "Variabelnya berupa angka biasa: sin(t) menganggapnya dalam radian. Hasil tanpa satuan memakai satuan desain.", + "ui.equation_parametric": "Parametrik", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Radius Ekuivalen", "ui.expand": "Perluas", "ui.exportDxf": "Ekspor DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "DESAIN", "ui.heading_draft": "DRAFT", "ui.heading_edit": "EDIT", + "ui.heading_equation_curve": "KURVA PERSAMAAN", "ui.heading_extrude": "EKSTRUSI", "ui.heading_fillet": "FILLET", "ui.heading_finish_editing": "SELESAI EDIT", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Impor…", "ui.infinite": "Tak hingga", "ui.insert": "Sisipkan", + "ui.insert_design.browse": "Jelajahi…", + "ui.insert_design.open_designs": "Desain terbuka", + "ui.insert_design.saved_file": "{name} memiliki perubahan yang belum disimpan, sehingga file tersimpannya yang disisipkan. Simpan desain itu, lalu jalankan Perbarui Tautan untuk memasukkan perubahannya.", + "ui.insert_design.unsaved": "Perubahan belum disimpan", "ui.inspect": "Inspeksi", "ui.intersect": "Irisan", "ui.isolate": "Isolasi", @@ -3994,12 +4433,29 @@ "ui.polygon": "Poligon", "ui.preferences": "Preferensi", "ui.press_a_key": "tekan tombol…", + "ui.print_face_cleared": "{body}: permukaan cetak dihapus", + "ui.print_face_input": "Permukaan cetak", + "ui.print_face_one_body": "Permukaan cetak milik satu bodi: kirim bodi itu sendiri untuk mengubahnya", + "ui.print_face_pick": "Pilih satu permukaan datar dari bodi padat untuk dijadikan permukaan cetak", + "ui.print_face_set": "{body}: permukaan cetak diatur; Kirim ke slicer meletakkan bodi di atasnya", "ui.profile": "Profil", "ui.profile_plane": "Bidang Profil", "ui.profiles": "Profil", "ui.project": "Proyeksi", "ui.project_face_edges_sketch": "Proyeksikan tepi muka ke sketsa baru", "ui.properties": "Properti", + "ui.properties.copy_matrix": "Salin matriks", + "ui.properties.first_moments": "Momen pertama", + "ui.properties.inertia": "Momen inersia", + "ui.properties.inertia_note": "Terhadap pusat massa, pada sumbu dunia (g·{unit}²)", + "ui.properties.inertia_unavailable": "Tidak tersedia: mesh sebuah bodi tidak tertutup.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mengukur…", + "ui.properties.measuring_failed": "Pengukuran berhenti sebelum selesai.", + "ui.properties.principal_axis": "Sumbu utama {number}", + "ui.properties.principal_moments": "Momen utama", "ui.pull_direction": "Arah tarik", "ui.quality": "Kualitas", "ui.quantity": "Jumlah", @@ -4045,6 +4501,7 @@ "ui.revolve": "Revolve", "ui.ribbon_exit": "Keluar", "ui.ribbon_labels": "Tampilkan teks di bawah ikon pita", + "ui.ribbon_menus_skip_pinned": "Kecualikan perintah yang disematkan dari dropdown", "ui.ribbon_pin_footer": "Perintah yang disematkan tampil di ribbon. Arahkan kursor ke baris untuk menyematkan atau melepas sematan.", "ui.ribbon_workspace": "Workspace", "ui.right": "Kanan", @@ -4062,6 +4519,7 @@ "ui.select": "Pilih", "ui.selectOther": "Pilih Lainnya…", "ui.selectTangentChain": "Pilih Rantai Tangen", + "ui.select_all": "Pilih Semua", "ui.selection": "Pilihan", "ui.selection_modifiers": "Pengubah pilihan", "ui.selection_shift_add_only": "Shift+klik menambah tanpa menghapus", @@ -4098,7 +4556,11 @@ "ui.sketch": "Sketsa", "ui.sketch_dimension": "Dimensi Sketsa", "ui.sketch_grid": "Grid Sketsa", + "ui.sketch_intersect_geometry": "Geometri", + "ui.sketch_intersect_projection_link": "Tautan Proyeksi", + "ui.sketch_intersect_selection_filter": "Filter Pilihan", "ui.sketch_modifiers": "Pengubah sketsa", + "ui.sketch_project_geometry": "Geometri", "ui.sketch_scale": "Skala Sketsa", "ui.sketches": "Sketsa", "ui.slice": "Iris", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Kelola", "ui.tab_sheet_metal": "Pelat Logam", "ui.tab_view": "Tampilan", + "ui.tab_weldments": "Konstruksi Las", "ui.tangent": "Tangen", "ui.taper_angle": "Sudut Taper", "ui.tapped": "Bertap", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Sudut Ujung", "ui.to_object": "Ke Objek", "ui.tool_direction": "Arah alat", + "ui.toolbar_tabs_hidden": "Semua tab toolbar disembunyikan. Klik + atau klik kanan di sini untuk menampilkan salah satunya.", "ui.top": "Atas", "ui.top_diameter": "Diameter atas", "ui.top_radius": "Radius atas", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Pusat tampilan", "ui.viewport_status_clear": "Matikan {name}", "ui.viewport_status_clear_hint": "Matikan {name} · Esc", + "ui.viewport_status_edges": "{count} tepi", + "ui.viewport_status_from": "dari {direction}", "ui.viewport_status_key": "Tampilkan legenda untuk {name}", "ui.viewport_status_minimum_radius": "Radius minimum", "ui.viewport_status_pairs": "{count} pasang", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Shaded dengan tepi", "ui.visual_style": "Gaya visual", "ui.visual_wireframe": "Wireframe", + "ui.wheel_zoom_speed": "Kecepatan zoom roda mouse", "ui.width": "Lebar", "ui.window_system": "Sistem jendela", "ui.window_system_auto": "Otomatis", @@ -4240,7 +4707,9 @@ "update.button": "Perbarui", "update.button_version": "Perbarui ke {version}", "update.check_automatically": "Periksa pembaruan secara otomatis", + "update.check_for_updates": "Periksa pembaruan", "update.check_now": "Periksa sekarang", + "update.check_unavailable": "Pemeriksaan pembaruan tidak tersedia untuk instalasi ini.", "update.checking": "Memeriksa…", "update.copy": "Salin", "update.dmg_opened": "Disk image terbuka dan terverifikasi: seret SolidCraft ke Applications, ganti salinan lama, lalu mulai ulang SolidCraft.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} terpasang. Mulai ulang untuk memakainya.", "update.installer_started": "Penginstal dimulai saat SolidCraft ditutup.", "update.later": "Ingatkan saya nanti", + "update.managed": "Pembaruan tersedia: perbarui SolidCraft di {manager}", "update.no_notes": "Rilis ini tidak memiliki catatan.", "update.package_hint": "Salinan ini berasal dari {kind}. Unduh paket baru, lalu pasang dengan:", "update.prefs_dev": "Ini adalah build pengembangan ({version}): tidak memeriksa pembaruan.", "update.prefs_flatpak": "Flatpak memperbarui salinan ini ({version}): jalankan {command} atau gunakan software center Anda. SolidCraft tidak memeriksa.", "update.prefs_label": "Pembaruan", + "update.prefs_managed": "Ini adalah SolidCraft {version}, dikelola oleh {manager}. Pemeriksaan meminta rilis terbaru ke GitHub dan tidak mengirim apa pun lagi; perbarui SolidCraft di {manager}.", "update.prefs_note": "Ini adalah SolidCraft {version} ({kind}). Pemeriksaan meminta rilis terbaru ke GitHub dan tidak mengirim apa pun lagi.", "update.prefs_web": "Muat ulang halaman untuk mendapatkan aplikasi web terbaru. Jika jaringan tidak tersedia atau pembaruan tidak selesai diunduh tepat waktu, versi tersimpan akan terbuka; unduhan yang sedang berjalan berlanjut untuk muat ulang berikutnya.", + "update.refresh_checking": "Memeriksa rilis terbaru…", + "update.refresh_fallback": "Tidak dapat memeriksa rilis yang lebih baru; memperbarui ke {version} yang ditemukan sebelumnya.", + "update.refresh_selected": "Memperbarui ke {version}.", "update.release_page": "Buka halaman rilis", "update.restart": "Mulai ulang sekarang", "update.restart_later": "Nanti", diff --git a/assets/locales/it.json b/assets/locales/it.json index 7e12bed41e..027040b04b 100644 --- a/assets/locales/it.json +++ b/assets/locales/it.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "I progetti aperti o salvati vengono visualizzati qui.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinato da Fino all'oggetto. Scegli Angolo per modificare l'estensione angolare.", "Diameter": "Diametro", + "Did this assembly open the wrong way up?": "Questo assieme si è aperto nel verso sbagliato?", + "Did this part open the wrong way up?": "Questa parte si è aperta nel verso sbagliato?", "Dimension new shapes": "Quota le nuove forme", "Direction": "Direzione", "Discard": "Scarta", @@ -204,6 +206,7 @@ "Edges": "Spigoli", "Edit": "Modifica", "Edit Profile Sketch": "Modifica schizzo del profilo", + "Edit shortcuts": "Modifica scorciatoie", "Enable rule": "Abilita regola", "Enabled": "Abilitato", "End Radius": "Raggio finale", @@ -466,6 +469,7 @@ "Radius": "Raggio", "Radius Fit": "Adattamento del raggio", "Ratio": "Rapporto", + "Read-only list: shortcuts can't be changed here.": "Elenco di sola lettura: qui le scorciatoie non si possono modificare.", "Reattach selected face": "Ricollega la faccia selezionata", "Recover": "Recupera", "Recover unsaved design?": "Recuperare il progetto non salvato?", @@ -599,6 +603,7 @@ "Square": "Quadrato", "Stand it up": "Raddrizza", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Raddrizza: un quarto di giro attorno a X, come singola lavorazione Sposta annullabile.", + "Stand this assembly up?": "Raddrizzare questo assieme?", "Stand this part up?": "Raddrizzare questa parte?", "Start angle": "Angolo iniziale", "Start bulge": "Rigonfiamento iniziale", @@ -649,6 +654,7 @@ "Third point": "Terzo punto", "This child is shared. Deleting it removes it from every copy of its parent.": "Questo elemento figlio è condiviso. Eliminandolo verrà rimosso da ogni copia del padre.", "This child is shared. Position changes apply to every copy of its parent.": "Questo elemento figlio è condiviso. Le modifiche alla posizione si applicano a ogni copia del padre.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Questo componente si trova in un sottoassieme inserito più volte: allinearlo lo sposta in ogni posizionamento.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Questa faccia non consente un collegamento persistente. Scegli un'altra faccia o usa esplicitamente un piano fisso.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Questa immagine non è ancora allegata a GitHub. Copiala o salvala, poi allegala su GitHub.", "Thread depth": "Profondità filetto", @@ -769,6 +775,8 @@ "at the face": "sulla faccia", "bom.group_children": "Sottoparti nella riga della loro parte", "browser.body_name": "Corpo{number}", + "browser.calibrate_click_a": "Fai clic sull'immagine per il punto A, oppure digitane le coordinate.", + "browser.calibrate_click_b": "Fai clic sull'immagine per il punto B, oppure digitane le coordinate.", "browser.calibrate_title": "Calibra: {name}", "browser.capture_many": "Sono stati spostati {count} componenti, ma le loro posizioni non sono state acquisite.", "browser.capture_note": "Acquisisci posizione mantiene la nuova posizione; Ripristina posizione riporta a quella precedente.", @@ -790,6 +798,11 @@ "cam.roller": "Rullo", "cam.roller_pick": "Rullo (facoltativo)", "cam.tip_pick": "Punta (facoltativa)", + "camera.shot_pitch": "Beccheggio", + "camera.shot_roll": "Rollio", + "camera.shot_yaw": "Imbardata", + "canvas.decal_place_hint": "Fai clic su una faccia per posizionare la decalcomania", + "canvas.place_hint": "Fai clic su un piano o una faccia per posizionare l'immagine di riferimento", "choose a profile": "scegli un profilo", "choose the cam's and the follower's joints": "scegli i giunti della camma e del seguace", "choose the joints to couple": "scegli i giunti da accoppiare", @@ -797,12 +810,16 @@ "click sketch curves": "fare clic sulle curve dello schizzo", "cmd.appearance_assign": "Aspetto", "cmd.appearance_chain": "Catena degli aspetti", + "cmd.appearance_copy": "Copia aspetto", + "cmd.appearance_favorite": "Aspetto preferito", "cmd.appearance_library": "Libreria degli aspetti", "cmd.appearance_list": "Elenca aspetti", "cmd.appearance_paint": "Applica aspetto", + "cmd.appearance_paste": "Incolla aspetto", "cmd.appearance_pick_up": "Preleva aspetto", "cmd.appearance_replace": "Sostituisci aspetto", "cmd.assembly_bom": "Distinta base", + "cmd.body.print_face": "Faccia di stampa", "cmd.body_rename": "Rinomina corpo", "cmd.browser_collapse_all": "Comprimi tutto", "cmd.browser_expand_all": "Espandi tutto", @@ -810,6 +827,7 @@ "cmd.browser_move": "Sposta elementi della struttura", "cmd.browser_order": "Ordina elementi della struttura", "cmd.browser_rename_group": "Rinomina gruppo", + "cmd.browser_reparent": "Sposta nel componente", "cmd.browser_ungroup": "Separa gruppo", "cmd.browser_visibility": "Mostra/Nascondi", "cmd.canvas_calibrate": "Calibra immagine di riferimento", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Proprietà della parte", "cmd.component_metadata_set": "Modifica proprietà della parte", "cmd.component_move_bodies": "Sposta corpi nel componente", + "cmd.component_move_features": "Sposta lavorazioni nel componente", "cmd.component_move_sketches": "Sposta schizzi nel componente", + "cmd.component_paste_from_design": "Incolla da un altro progetto", "cmd.component_paste_new": "Incolla come nuovo", "cmd.component_properties": "Proprietà fisiche del componente", "cmd.component_relink": "Ricollega sorgente", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Traiettoria del movimento", "cmd.motion_trace_edit": "Modifica traccia di movimento", "cmd.motion_trace_keep": "Mantieni traiettoria del movimento", + "cmd.occurrence_align": "Allinea occorrenza", "cmd.occurrence_copy": "Incolla (istanza)", "cmd.occurrence_drag": "Trascina occorrenza", "cmd.occurrence_ground": "Fissa a terra", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Rimuovi associazione della superficie", "cmd.render_library_targets": "Trova destinazioni della libreria di rendering", "cmd.render_lights_add": "Aggiungi luce", + "cmd.render_lights_duplicate": "Duplica luce di rendering", + "cmd.render_lights_list": "Elenca luci di rendering", + "cmd.render_lights_mute": "Silenzia luce", + "cmd.render_lights_remove": "Rimuovi luce di rendering", + "cmd.render_lights_set": "Imposta luce di rendering", "cmd.render_lights_solo": "Isola luce", "cmd.render_optics_bind": "Salva associazione ottica", "cmd.render_optics_unbind": "Rimuovi associazione ottica", "cmd.render_output_size": "Imposta dimensioni di uscita del rendering", "cmd.render_preflight": "Verifica richiesta di rendering", + "cmd.render_preflight_copy": "Copia rapporto di verifica del rendering", "cmd.render_preset_apply": "Applica studio nativo", "cmd.render_preset_resolve": "Verifica studio nativo", "cmd.render_quality_prepare": "Verifica budget di campionamento fisso", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Renderizza immagine", "cmd.render_status": "Avanzamento del rendering", "cmd.select_clear": "Deseleziona tutto", + "cmd.settings_profile_apply": "Applica profilo delle impostazioni", + "cmd.settings_profile_delete": "Elimina profilo delle impostazioni", + "cmd.settings_profile_export": "Esporta profilo delle impostazioni", + "cmd.settings_profile_import": "Importa profilo delle impostazioni", + "cmd.settings_profile_list": "Profili delle impostazioni", + "cmd.settings_profile_rename": "Rinomina profilo delle impostazioni", + "cmd.settings_profile_save": "Salva profilo delle impostazioni", "cmd.sheet_convert": "Converti in lamiera", "cmd.sheet_export_bend_table": "Esporta tabella delle pieghe", "cmd.sheet_export_dxf": "Esporta sviluppo piano come DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Ripiega", "cmd.sheet_rules": "Elenca regole per lamiera", "cmd.sheet_unfold": "Spiega", + "cmd.sketch.construction_line": "Retta di costruzione", + "cmd.sketch.construction_line.angle": "Retta di costruzione inclinata", + "cmd.sketch.construction_line.bisector": "Bisettrice", + "cmd.sketch.construction_line.horizontal": "Retta di costruzione orizzontale", + "cmd.sketch.construction_line.parallel": "Retta di costruzione parallela", + "cmd.sketch.construction_line.perpendicular": "Retta di costruzione perpendicolare", + "cmd.sketch.construction_line.tangent_at": "Tangente in un punto della curva", + "cmd.sketch.construction_line.tangent_external": "Tangente per un punto esterno", + "cmd.sketch.construction_line.tangent_two": "Tangente comune", + "cmd.sketch.construction_line.vertical": "Retta di costruzione verticale", "cmd.sketch_arc3d": "Arco 3D", "cmd.sketch_arc_center_point": "Arco per centro", "cmd.sketch_arc_tangent": "Arco tangente", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Dimensioni del disegno", "cmd.sketch_edit": "Modifica schizzo", "cmd.sketch_edit_text": "Modifica testo", + "cmd.sketch_equation_curve": "Curva da equazione", + "cmd.sketch_equation_curve_edit": "Modifica curva da equazione", "cmd.sketch_export_dxf": "Salva come DXF", "cmd.sketch_finish_auto_constrain": "Termina con vincoli automatici", "cmd.sketch_fit_curves_to_section": "Adatta curve alla sezione della mesh", @@ -1198,6 +1244,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_items": "Elementi di stato della vista", "cmd.view_ambient_occlusion": "Occlusione ambientale", "cmd.view_angle_readout": "Angoli della vista", "cmd.view_apply": "Ripristina vista con nome", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Normale alla selezione", "cmd.view_previous_tab": "Scheda precedente", "cmd.view_rename": "Rinomina vista con nome", + "cmd.view_render_frame": "Guide di inquadratura del rendering", "cmd.view_right": "Vista destra", "cmd.view_save": "Nuova vista con nome", "cmd.view_shots_strip": "Striscia degli scatti", @@ -1229,10 +1277,14 @@ "color.copy": "Fai clic per copiare i valori del colore", "color.edit": "Fai clic per modificare il colore", "color.gamma": "Valori del colore mostrati nello spazio gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Inserisci #rrggbb o #rrggbbaa; con sei cifre l'opacità resta invariata", + "color.hex_hint": "#rrggbb", "color.hue": "Tonalità", "color.linear": "Valori del colore mostrati nello spazio lineare 0-1", "color.saturation_value": "Saturazione e valore", "color.selected": "Colore selezionato", + "config.column_filter_empty": "Nessuna colonna corrispondente", "contact.components_count": "{count} componenti", "contact.enabled_label": "abilitato", "coupling.gear_1": "Ruota dentata 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Fai doppio clic su un’altra quota di controllo per inserirne il nome", "dialog.dimension_name": "Quota: {name}", "dialog.edit_title": "MODIFICA {title}", + "dialog.no_shown_body": "Nessun corpo visibile da modificare: mostrane uno o scegli Nuovo corpo", "dialog.select": "↖ Seleziona", "dialog.selected_count": "↖ Selezionati: {count}", "dialog.selection_guidance": "Seleziona un dato di ingresso per modificarlo. Fai clic sugli oggetti selezionati per rimuoverli; anche Ctrl/Cmd-clic ne alterna la selezione. Controlla l'anteprima, poi premi OK.", @@ -1346,8 +1399,20 @@ "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.mit": "Licenza MIT", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Crea una retta di costruzione orizzontale infinita passante per un punto. Il suo vincolo orizzontale resta valido anche nelle modifiche successive.", + "help.sketch.construction_line.parallel": "Seleziona una linea retta dello schizzo, poi posiziona una retta di costruzione infinita parallela. Fai clic destro per scegliere un altro riferimento.", + "help.sketch.construction_line.perpendicular": "Seleziona una linea retta dello schizzo, poi posiziona una retta di costruzione infinita perpendicolare. Fai clic destro per scegliere un altro riferimento.", + "help.sketch.construction_line.tangent_at": "Crea una tangente in un punto di un cerchio o di un arco di cerchio, oppure in un estremo di una spline aperta o di una conica. Seleziona la curva, poi il suo punto di contatto.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Elimina", "hole_presets.delete_prompt": "Eliminare questo preset personale? I fori esistenti restano invariati.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Queste parti sono corpi di un unico componente: {bodies}. Un giunto muove un componente rispetto a un altro, quindi ogni parte richiede un componente distinto.", "joint.rigid_group_one_component": "Questi {count} corpi sono tutti in un solo componente (o nella progettazione stessa): usa prima {command}.", "joint.rigid_group_select_components": "Seleziona due o più componenti.", + "joint_move.blocked_hint": "Controlla il tipo di giunto (ad esempio Rigido), il fissaggio a terra, i limiti del giunto e i valori comandati da parametri.", + "joint_move.blocked_title": "Impossibile spostare questa parte", + "joint_move.coarse": "Passo di rotazione vicino all'asse (°)", + "joint_move.distance": "Distanza dall'inizio", + "joint_move.done": "Fatto", + "joint_move.fine": "Passo di rotazione lontano dall'asse (°)", + "joint_move.fixed": "Questa parte non ha alcun asse di giunto mobile.", + "joint_move.help": "Sposta — trascina una freccia o un anello. Esc annulla il trascinamento in corso.", + "joint_move.legacy_hint": "Disattiva per usare il pannello Sposta precedente.", + "joint_move.precision": "Trascina oltre la guida tratteggiata per una rotazione fine.", + "joint_move.rotation": "Rotazione dall'inizio", + "joint_move.select": "Seleziona una faccia o una parte dell'assieme, poi scegli Sposta.", + "joint_move.settings": "Spostamento nell'assieme", + "joint_move.step": "Passo", + "joint_move.use_handles": "Usa le maniglie di movimento dei giunti per Sposta nell'assieme", "keymap.command_id": "ID comando", "keymap.empty": "Nessun comando corrispondente", "keymap.use_anyway": "Usa comunque {key} qui", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+trascinamento con il pulsante sinistro orbita, Alt+pulsante centrale esegue la panoramica, Alt+pulsante destro lo zoom; anche il trascinamento con il pulsante centrale esegue la panoramica", "nav.openscad.help": "Il trascinamento sinistro nello spazio vuoto orbita, Shift+trascinamento sinistro seleziona con un riquadro, il trascinamento sulla geometria dello schizzo la modifica; il trascinamento destro o centrale fa la panoramica, Shift+destro o Shift+centrale fa lo zoom", "nav.perspective.sketch": "La modifica dello schizzo usa la vista ortogonale. Termina lo schizzo per passare alla prospettiva.", + "nav.right_drag_orbits.help": "Disattivato: il trascinamento con il pulsante destro non muove la camera e il pulsante destro apre solo il menu contestuale. Scegliendo l'assegnazione del mouse SolidWorks l'opzione si disattiva.", + "nav.right_drag_orbits.label": "Orbita con il trascinamento destro", "nav.solidworks.help": "Trascina con il tasto centrale per orbitare, Ctrl+centrale per la panoramica, Shift+centrale per lo zoom, trascina con il destro per orbitare", "nav.tinkercad.help": "Trascina con il tasto destro per orbitare, con il centrale o Shift+destro per la panoramica", "nav.touchpad.help": "Tieni premuto Shift e muovi il puntatore per eseguire una panoramica; tieni premuto Alt/Option e muovilo per orbitare. Non serve premere pulsanti del mouse. Lo scorrimento verticale regola lo zoom.", @@ -1698,12 +1782,15 @@ "parts.dimension.thickness": "Spessore", "parts.dimension.width": "Larghezza", "parts.family.bearing": "Cuscinetto radiale a sfere (ingombro)", + "parts.family.button_head_cap_screw": "Vite a testa bombata con esagono incassato", "parts.family.dowel_pin": "Spina cilindrica", "parts.family.hex_bolt": "Vite a testa esagonale", "parts.family.hex_nut": "Dado esagonale", "parts.family.set_screw": "Grano a esagono incassato, punta piana", "parts.family.socket_head_cap_screw": "Vite a testa cilindrica con esagono incassato", "parts.family.washer": "Rondella piana", + "parts.holes": "Fori (facoltativo)", + "parts.placement_hint": "scegli uno o più fori per alloggiare una parte in ciascuno, oppure OK la colloca nell'origine", "pattern.alignment_2": "Allineamento 2", "pattern.alignment_hint": "Il conteggio include l'originale. Centro distribuisce le copie sui due lati; Inverti sceglie il lato con la copia in più per totali pari.", "pattern.direction_1": "Direzione 1", @@ -1744,6 +1831,8 @@ "prefs.anti_aliasing_note": "Ammorbidisce i bordi seghettati.", "prefs.applies_on_restart": "Si applica al riavvio", "prefs.assistant": "Assistente", + "prefs.auto_operation": "Scegli l'operazione automaticamente", + "prefs.auto_operation_note": "Le finestre delle lavorazioni scelgono Unisci, Nuovo corpo o Taglia in base a ciò che tocca il nuovo materiale, finché non ne scegli una tu. Disattivato: mantengono l'operazione con cui si aprono.", "prefs.axes": "Assi", "prefs.axis": "Asse", "prefs.bodies_folders": "Cartelle dei corpi", @@ -1754,6 +1843,11 @@ "prefs.bottom_centre": "In basso al centro", "prefs.bottom_right": "In basso a destra", "prefs.browser": "Struttura", + "prefs.browser_eye_left": "Occhio accanto alla freccia di espansione", + "prefs.browser_eye_left_note": "Le righe della Struttura mostrano l'occhio mostra/nascondi accanto alla freccia di espansione, come prima, invece che in una colonna all'estremità destra delle righe.", + "prefs.browser_reveal_on_hover": "Mostra al passaggio del puntatore", + "prefs.browser_show_note": "Visibile mantiene la Struttura sullo schermo. Mostra al passaggio del puntatore la tiene nascosta finché il puntatore non passa sulla sua posizione o sulla sottile linguetta al suo bordo sinistro, e resta aperta finché il puntatore è al suo interno o un suo menu è aperto.", + "prefs.browser_shown": "Visibile", "prefs.built_in": "Integrato", "prefs.button_left": "Trascinamento sinistro", "prefs.button_middle": "Trascinamento centrale", @@ -1801,6 +1895,8 @@ "prefs.graphics_card_restart": "Ha effetto dopo il riavvio di SolidCraft. WGPU_ADAPTER_NAME prevale su questa scelta.", "prefs.graphics_note": "Scegli tra velocità e fluidità dell'immagine. Ogni scelta mostra il suo effetto su un esempio.", "prefs.graphics_presets_note": "Una preimpostazione regola insieme le scelte qui sotto.", + "prefs.hide_constraint_glyphs_zoomed_out": "Nascondi i simboli dei vincoli quando lo zoom è ridotto", + "prefs.hide_constraint_glyphs_zoomed_out_note": "I simboli dei vincoli scompaiono dalla vista quando lo zoom sullo schizzo è ridotto al punto da affollarlo. Disattivato: restano a una dimensione fissa sullo schermo a qualsiasi zoom.", "prefs.history": "Cronologia", "prefs.history_origin_bodies": "Mostra origine e corpi in alto", "prefs.history_origin_bodies_note": "La Cronologia a sinistra o sotto la Struttura inizia con la cartella Corpi del componente attivo e con l'Origine, con occhi e menu come nella Struttura. La Cronologia in basso non cambia.", @@ -1824,6 +1920,8 @@ "prefs.layout_note": "Compatta: pannelli ancorati ai bordi della finestra. La trasparenza della Struttura è disponibile in Flottante. Cambia il lato dell'Assistente nella pagina Assistente.", "prefs.left": "Sinistra", "prefs.left_click": "Clic sinistro", + "prefs.line_drag_draws_one": "Premi e trascina per disegnare una linea", + "prefs.line_drag_draws_one_note": "Nello strumento Linea, premere, trascinare e rilasciare disegna una linea dal punto di pressione a quello di rilascio, e il disegno termina lì. I clic disegnano comunque una catena. Disattivato: un trascinamento posiziona un punto dove viene rilasciato, come un clic.", "prefs.linked_designs": "Progetti collegati", "prefs.live_input": "Input in tempo reale", "prefs.live_preview": "Anteprima dal vivo", @@ -1837,6 +1935,9 @@ "prefs.max_file_size_step": "File STEP e di importazione", "prefs.max_file_size_web": "L’app web apre file STEP e di importazione fino a 512 MB e progetti fino a 256 MB: una scheda del browser non può contenere file più grandi. L’app desktop permette di aumentare questi limiti.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Icone grandi", + "prefs.menu_list": "Elenco", + "prefs.menus": "Menu", "prefs.minutes": "minuti", "prefs.minutes_suffix": " min", "prefs.mouse": "Mouse e trackpad", @@ -1856,14 +1957,40 @@ "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.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", "prefs.placement": "Posizione", "prefs.prefer_free_sketch_points": "Preferisci i punti dello schizzo mobili quando i punti si sovrappongono", "prefs.preview_choices": "Anteprima delle tue scelte", + "prefs.print_face_highlight": "Evidenziazione della faccia di stampa", + "prefs.print_face_highlight_tip": "Intensità della tinta e del contorno della faccia di stampa di un corpo nella vista. 0 % non mostra evidenziazione.", + "prefs.profile_active": "Profilo attivo", + "prefs.profile_applied": "Profilo applicato.", + "prefs.profile_changed": "Le impostazioni sono cambiate da quando il profilo è stato salvato. Salva le conserva nel profilo.", + "prefs.profile_deleted": "Profilo eliminato.", + "prefs.profile_export": "Esporta…", + "prefs.profile_exported": "Profilo esportato.", + "prefs.profile_imported": "Profilo importato. Sceglilo in Profilo attivo per passare a esso.", + "prefs.profile_name_hint": "Nome del profilo, ad es. Portatile", + "prefs.profile_rename": "Rinomina", + "prefs.profile_renamed": "Profilo rinominato.", + "prefs.profile_save": "Salva", + "prefs.profile_saved": "Impostazioni attuali salvate nel profilo.", + "prefs.profile_skipped": "Profilo applicato; {n} impostazioni hanno mantenuto il valore attuale (vedi il registro).", + "prefs.profiles": "Profili delle impostazioni", + "prefs.profiles_note": "Scorciatoie, barra degli strumenti, navigazione, menu radiale e impostazioni dell'interfaccia, raccolti sotto un nome e cambiati tutti insieme.", + "prefs.project_options_dialog": "Finestra delle opzioni di Proietta", + "prefs.project_options_dialog_note": "Attivo: scegli Geometria o Corpi e Collegamento di proiezione prima di proiettare. Disattivato: mantiene il flusso di lavoro precedente con selezione diretta.", "prefs.quality_default": "predefinito", "prefs.quality_fastest": "più veloce", "prefs.quality_slowest": "più lento", "prefs.quality_smoother": "più fluido", + "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", + "prefs.ribbon_reveal": "Mostra i risultati della ricerca nella barra multifunzione", + "prefs.ribbon_reveal_note": "Passando il puntatore su un risultato in Cerca comandi si vede dove si trova il comando nella barra multifunzione: la sua scheda, il suo pulsante o la sua riga nel menu a discesa di un pannello. Un comando avviato dalla ricerca lampeggia lì, e un comando in esecuzione da un menu a discesa ne evidenzia il pannello.", "prefs.right_click": "Clic destro", "prefs.saving": "Salvataggio automatico e ripristino", "prefs.scale_note": "Anche Ctrl + / Ctrl − nella finestra. I pulsanti sono 28 px al 100%.", @@ -1872,21 +1999,34 @@ "prefs.search_empty": "Nessuna impostazione corrispondente", "prefs.select": "Seleziona", "prefs.select_one_item": "Seleziona un elemento", + "prefs.select_other_hidden": "Seleziona la geometria nascosta con Seleziona altro", + "prefs.select_other_hidden_help": "Attivo: include i corpi e gli schizzi nascosti e gli spigoli occlusi vicino al puntatore. Disattivato: usa l'elenco originale della geometria visibile sotto il puntatore.", "prefs.selecting": "Selezione", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift solo aggiunge; Ctrl / Cmd alterna", "prefs.shift_add_note": "Attivo: Shift aggiunge, Ctrl / Cmd alterna. Disattivo (predefinito): entrambi alternano.", "prefs.shortcuts": "Scorciatoie", + "prefs.single_surface_analysis": "Usa una sola analisi della superficie", + "prefs.single_surface_analysis_help": "Se disattivato, l'ombreggiatura della superficie e Continuità possono restare attive insieme. Se attivato, resta solo l'ultima analisi avviata o modificata.", + "prefs.sketch_align_points": "Allinea agli altri punti dello schizzo durante il disegno", + "prefs.sketch_align_points_note": "Durante il disegno, un punto si allinea contemporaneamente in orizzontale con un punto dello schizzo e in verticale con un altro, con una guida punteggiata, e il clic mantiene entrambi gli allineamenti. Disattivato: solo con il punto precedente, un asse alla volta.", "prefs.sketch_conflict_colour": "Geometria in conflitto nel colore di conflitto", "prefs.sketch_conflict_colour_note": "Quando i vincoli di uno schizzo sono in conflitto, anche le curve e i punti interessati da un vincolo in errore vengono disegnati con il colore di conflitto, non solo il suo simbolo o la sua quota. Disattivato: mantengono i colori abituali.", + "prefs.sketch_construction_lines": "Strumenti estesi per le rette di costruzione", "prefs.sketch_exit_view": "Al termine di uno schizzo", "prefs.sketch_exit_view.keep": "Mantieni la vista attuale", "prefs.sketch_exit_view.previous": "Torna alla vista precedente", "prefs.sketch_exit_view.unless_rotated": "Torna, se non ruotata", "prefs.sketch_exit_view_note": "Dove va la vista dopo Termina schizzo, Annulla o un doppio Esc.", + "prefs.sketch_open_look_at": "Ruota la vista verso lo schizzo quando si apre", + "prefs.sketch_open_look_at_note": "Disattivato: creando o modificando uno schizzo la vista resta com'è. Vista normale orienta la vista sullo schizzo quando vuoi.", + "prefs.sketch_recent_snap": "Aggancia automaticamente ai centri e punti medi recenti", + "prefs.sketch_recent_snap_note": "Durante il disegno, il cursore si aggancia dove si incrociano le guide degli ultimi due centri di cerchio o punti medi di segmento. Disattivato: queste guide restano solo visive e il cursore non si sposta.", "prefs.sketch_selection": "Schizzo e selezione", "prefs.sketch_selection_heading": "Schizzo e selezione", "prefs.sketch_selection_note": "Come si agganciano gli schizzi e come selezionano i clic.", + "prefs.sketch_state_icons": "Icone degli schizzi nel colore del loro stato", + "prefs.sketch_state_icons_note": "Nella Cronologia e nella Struttura, l'icona di uno schizzo assume il colore del suo stato (sottovincolato, completamente vincolato o in conflitto) e il suo suggerimento indica lo stato. Disattivato: ogni schizzo ha la stessa icona.", "prefs.sketch_tab_always": "Mantieni visibile la scheda Schizzo", "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", @@ -1896,6 +2036,11 @@ "prefs.source": "Fonte", "prefs.speed": "Velocità", "prefs.stand_up_note": "Dopo l'apertura di un file STEP o IGES con Z verso l'alto, proponi un quarto di giro attorno a X.", + "prefs.stand_up_unknown": "Per i file di programmi sconosciuti", + "prefs.stand_up_unknown.ask": "Chiedi in una finestra di dialogo", + "prefs.stand_up_unknown.note": "Mostra una nota", + "prefs.stand_up_unknown.off": "Non proporre", + "prefs.stand_up_unknown_note": "Come viene fatta la proposta quando l'intestazione del file non indica alcun programma noto per salvare con Y verso l'alto. Una nota viene mostrata solo quando il file sembra coricato; Chiedi in una finestra di dialogo chiede per ogni file di questo tipo, come prima.", "prefs.status_bar": "Barra di stato", "prefs.status_bar_note": "Una barra sottile lungo il bordo inferiore della finestra mostra cosa si sta modificando, lo stato dello schizzo aperto nel suo colore e le unità del progetto; un clic sulle unità le cambia. Lo stato dello schizzo non compare più nell'angolo della vista.", "prefs.status_done": "Fatto", @@ -1921,7 +2066,14 @@ "prefs.toast_place": "Avvisi", "prefs.toggle_item": "Aggiungi l'elemento o toglilo di nuovo", "prefs.toolbar": "Barra degli strumenti", + "prefs.toolbar_tabs": "Schede della barra degli strumenti", "prefs.top": "In alto", + "prefs.trackpad_zoom_modifier": "Zoom con lo scorrimento a due dita tenendo premuto", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Tieni premuto solo questo tasto per eseguire lo zoom sul puntatore con lo scorrimento verticale. Disattivato mantiene il comportamento di scorrimento attuale.", + "prefs.trackpad_zoom_modifier.off": "Disattivato", "prefs.translucent": "Traslucido", "prefs.under_browser": "Sotto la Struttura", "prefs.under_cursor": "Sotto il cursore", @@ -1931,16 +2083,24 @@ "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.", "prefs.viewport": "Vista", + "prefs.viewport_analysis_status": "Mostra lo stato di analisi della vista", + "prefs.viewport_analysis_status_help": "Mostra le analisi attive sopra la vista, apri le loro legende da ogni elemento e usa Esc per disattivare l'analisi più recente. Disattiva per ripristinare le legende negli angoli e il comportamento precedente di Esc.", "prefs.viewport_background": "Sfondo", "prefs.viewport_background_colour": "Colore", "prefs.viewport_background_theme": "Sfumatura del tema", "prefs.wheel": "Rotellina", "prefs.wheel_trackpad": "Rotellina e trackpad", "prefs.workspace": "Area di lavoro", + "prefs.zoom_stop_at_model": "Ferma lo zoom sul modello", + "prefs.zoom_stop_at_model_tip": "Il mouse 3D e l'app del dispositivo eseguono lo zoom verso la superficie al centro della vista; lo zoom con trascinamento mantiene il punto in cui è iniziato il trascinamento. Disattivato: lo zoom può attraversare il modello. Rotellina e pizzicamento usano separatamente Zoom verso il cursore", "prefs.zoom_toward_cursor": "Zoom verso il cursore", "properties.title": "Proprietà: {name}", "recovery.days_ago": "{count} giorni fa", "recovery.detail": "{file} · {features} feature · salvato automaticamente {when}", + "recovery.discard_all": "Scarta tutto", + "recovery.discard_all_detail": "Questa azione non può essere annullata.", + "recovery.discard_all_title": "Scartare {count} salvataggi automatici?", + "recovery.discard_all_title_one": "Scartare 1 salvataggio automatico?", "recovery.hours_ago": "{count} h fa", "recovery.just_now": "proprio ora", "recovery.min_ago": "{count} min fa", @@ -2025,6 +2185,7 @@ "render.appearance_editor.weight": "Peso", "render.appearance_physics_separate": "L'aspetto visivo non assegna densità, dati termici o altre proprietà ingegneristiche.", "render.appearances": "Aspetti", + "render.assign_help": "Fai clic per applicare alla selezione; trascina per vedere l'anteprima e rilascia, oppure, senza nulla selezionato, fai clic per applicare con il pennello.", "render.availability_native": "Composizione nativa; verifica prima di applicare", "render.availability_projection": "Proiezione supportata; vedi ambito e omissioni", "render.availability_reference": "Dati di riferimento; non è un'assegnazione automatica", @@ -2063,6 +2224,7 @@ "render.bloom_threshold": "Soglia lineare delle alte luci", "render.browse_capabilities": "Esamina il catalogo completo delle funzionalità", "render.browse_environments": "Sfoglia gli ambienti HDR originali", + "render.browse_looks": "Sfoglia…", "render.browse_native_scenes": "Sfoglia gli studi nativi", "render.browse_physical": "Esamina i riferimenti fisici", "render.browse_quality": "Esamina le specifiche di qualità", @@ -2086,6 +2248,82 @@ "render.check_opaque": "Trasparenza: solo superfici opache; l'alfa dello sfondo è supportato.", "render.check_sorted_alpha": "Trasparenza: approssimazione alfa ordinata.", "render.check_transmission": "Trasparenza: trasmissione fisica.", + "render.children.absolute_floor": "Soglia minima assoluta", + "render.children.adaptive": "Adattivo", + "render.children.add_look_reason": "Aggiungi un look per modificarne l'attivazione e l'intensità.", + "render.children.all_shots": "Tutte le inquadrature", + "render.children.bloom": "Bloom", + "render.children.check": "Verifica", + "render.children.check_now": "Verifica ora", + "render.children.chromatic": "Scostamento cromatico", + "render.children.compact": "Compatto", + "render.children.contrast": "Contrasto", + "render.children.copy_image": "Copia", + "render.children.copy_job": "Copia JSON del lavoro", + "render.children.copy_request": "Copia JSON della richiesta", + "render.children.coverage_error": "Errore di copertura", + "render.children.cpu": "Ray tracing su CPU", + "render.children.denoise": "Riduci il rumore nell'immagine finale", + "render.children.depth": "Profondità del percorso", + "render.children.distortion": "Distorsione", + "render.children.edge_samples": "Campioni dei bordi", + "render.children.effects_banner": "Questi effetti richiedono il path tracing su GPU. Aprire questa sezione non cambia mai il metodo.", + "render.children.enable_adaptive": "Abilita i piloti indipendenti per modificare i limiti adattivi.", + "render.children.enable_bloom": "Abilita il bloom per modificarne intensità, soglia e raggio.", + "render.children.finishing": "Finitura", + "render.children.flare": "Flare", + "render.children.fresnel": "Strati di Fresnel", + "render.children.ghosts": "Anelli fantasma", + "render.children.glass": "Trasporto nel vetro", + "render.children.go_to": "Vai a {editor}", + "render.children.grain": "Grana", + "render.children.halo": "Alone", + "render.children.hdr": "HDR", + "render.children.keep_master": "Conserva il master lineare grezzo", + "render.children.last_result": "Ultimo: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Effetti obiettivo", + "render.children.look_summary": "{tone} · {count} look", + "render.children.looks": "Look creativi", + "render.children.memory": "Memoria", + "render.children.method": "Metodo", + "render.children.minimum": "Minimo", + "render.children.needs_cpu": "Richiede il ray tracing su CPU. Aprire questa sezione non cambia mai il metodo.", + "render.children.nested": "Dielettrici annidati certificati", + "render.children.not_checked": "Non verificato", + "render.children.path": "Path tracing", + "render.children.pilots": "Campioni pilota", + "render.children.preview": "Payload dell'anteprima", + "render.children.progressive_reason": "Scegli Progressiva per modificarne il numero di campioni e il seme.", + "render.children.radius": "Raggio", + "render.children.reflect_depth": "Profondità delle riflessioni", + "render.children.reflect_samples": "Campioni di riflessione", + "render.children.regrade": "Correzione colore", + "render.children.relative_error": "Errore relativo", + "render.children.render": "Rendering", + "render.children.render_shot": "Renderizza inquadratura", + "render.children.renderer": "Motore di rendering", + "render.children.renderers": "Motori di rendering", + "render.children.request": "Questa richiesta", + "render.children.reset_finishing": "Reimposta finitura", + "render.children.review_quality": "Esamina una specifica di qualità…", + "render.children.rgb_gain": "Guadagno RGB", + "render.children.samples": "Campioni", + "render.children.sampling": "Campionamento", + "render.children.saturation": "Saturazione", + "render.children.scene_summary": "{bodies} corpi · {triangles} triangoli", + "render.children.seed": "Seme", + "render.children.sharpening": "Nitidezza", + "render.children.single_image": "Immagine singola", + "render.children.soft_focus": "Messa a fuoco morbida", + "render.children.streak": "Striatura", + "render.children.strength": "Intensità", + "render.children.studio": "Studio", + "render.children.threshold": "Soglia", + "render.children.tone": "Tono", + "render.children.under_hood": "Dettagli tecnici", + "render.children.vignette": "Vignettatura", + "render.children.workstation": "Workstation", + "render.choose_look": "Scegli prima un aspetto in Aspetti", "render.chromatic_offset": "Scostamento cromatico dei bordi", "render.clear_stage": "Rimuovi la scenografia salvata", "render.copy_request": "Copia JSON", @@ -2337,8 +2575,33 @@ "render.flare_strength": "Intensità del flare sulle alte luci", "render.flare_threshold": "Soglia del flare lineare della scena", "render.fold_browser": "Comprimi struttura", + "render.frame_aspect": "Proporzioni", + "render.frame_centre_mark": "Segno centrale", + "render.frame_copy_limit": "Nessun nome disponibile per l'inquadratura duplicata", + "render.frame_copy_name": "{name} copia {number}", + "render.frame_custom": "Personalizzato", + "render.frame_distance_unit": " × dimensione", + "render.frame_duplicate": "Duplica", + "render.frame_exposure": "Esposizione", + "render.frame_fov": "Campo visivo", + "render.frame_guides": "Guide", + "render.frame_invalid_aspect": "Preimpostazione delle proporzioni non valida", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Nome", + "render.frame_output_size": "Dimensioni di uscita", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Ritratto", "render.frame_presets": "Preimpostazioni del fotogramma", "render.frame_presets_pending": "In arrivo con l'editor delle inquadrature", + "render.frame_projection": "Proiezione", + "render.frame_render_shot": "Renderizza questa inquadratura", + "render.frame_rule_thirds": "Regola dei terzi", + "render.frame_safe_area": "Area di sicurezza", + "render.frame_shot_title": "Inquadratura · {name}", + "render.frame_square": "1:1 · Quadrato", + "render.frame_tall": "9:16 · Verticale", + "render.frame_thirds": "Terzi", + "render.frame_wide": "16:9 · Panoramico", "render.gpu_depth": "Profondità massima del percorso", "render.gpu_options_help": "Campioni, profondità, seed ed esposizione vengono salvati con questa impostazione. Viene salvato solo il metodo di generazione attivo.", "render.gpu_path_failed": "Path tracer GPU non disponibile", @@ -2366,6 +2629,7 @@ "render.heading_results": "RISULTATI", "render.heading_shots": "INQUADRATURE", "render.hide_shots_label": "Nascondi la barra delle inquadrature", + "render.hover_look": "{object} · {look} · fai clic per selezionare", "render.image_advanced": "Controlli GPU avanzati", "render.image_cancelled": "Rendering annullato", "render.image_cancelling": "Annullamento", @@ -2512,17 +2776,95 @@ "render.library_uniform_ack": "Usa l'approssimazione uniforme senza effetti spaziali o avanzati", "render.library_usable": "Proiezioni eseguibili", "render.library_users": "Usato da", + "render.light_aim": "Punta verso", "render.light_anchor_pending": "La modifica del sistema di coordinate delle luci non è ancora supportata.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Scegli il ray tracing su CPU o uno studio GPU esplicito per modificare le luci. Le luci dello studio GPU di vecchio tipo restano di sola lettura.", + "render.light_behaviour": "COMPORTAMENTO", + "render.light_default_fill": "Riempimento", + "render.light_default_key": "Principale", + "render.light_default_number": "Luce {number}", + "render.light_default_rim": "Controluce", + "render.light_delete": "Elimina", + "render.light_distance": "Distanza", + "render.light_duplicate": "Duplica", + "render.light_elevation": "Elevazione", + "render.light_gpu_punctual": "Le luci puntiformi e spot sono disponibili con il ray tracing su CPU.", + "render.light_hard_shadow": "Questa luce ha ombre nette; la morbidezza dell'emettitore non è disponibile.", + "render.light_intensity": "Intensità", + "render.light_missing": "Questa luce non esiste più. Seleziona una luce nella Struttura.", + "render.light_place_highlight": "Posiziona in base al riflesso", + "render.light_place_pending": "Il posizionamento in base al riflesso richiede la selezione delle luci nella vista e non è ancora supportato.", + "render.light_position": "POSIZIONE", + "render.light_preview_help": "La vista live è senza ombre; le luci ad area usano un'approssimazione con un campione centrale. Il rendering usa il modello di luce completo.", + "render.light_product_centre": "Centro del prodotto", + "render.light_size": "Dimensioni", + "render.light_softness": "Morbidezza", + "render.light_temperature": "Temperatura di colore", + "render.light_temperature_help": "Tinta approssimata del corpo nero; salvata come RGB lineare.", + "render.light_type": "Tipo", + "render.light_type_area": "Area", + "render.light_type_point": "Puntiforme", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Sole", + "render.light_view_only": "Isola e silenzia agiscono su questa vista e sui suoi rendering; non vengono mai salvati nel progetto.", + "render.light_world_origin": "Origine globale", + "render.lighting_aim_point": "Punto di mira", + "render.lighting_apply": "Applica preimpostazione", + "render.lighting_area_samples": "Campioni delle luci ad area", + "render.lighting_budget": "Budget", + "render.lighting_budget_help": "I conteggi sono di sola lettura. Aggiungi, duplica o elimina luci per cambiarli; i limiti dipendono dal metodo.", + "render.lighting_colour": "Colore", + "render.lighting_consent": "Sostituisci le luci direzionali con questa preimpostazione", + "render.lighting_custom": "Luci personalizzate", + "render.lighting_custom_help": "Scegliendo Personalizzato, la preimpostazione diventa un insieme di luci modificabili, ognuna con una riga nella struttura.", + "render.lighting_direction": "Direzione X Y Z", + "render.lighting_direction_help": "Oppure digita una direzione verso la luce (X Y Z).", + "render.lighting_distance_unit": " × dimensione", + "render.lighting_edge_u": "Vettore del lato U", + "render.lighting_edge_v": "Vettore del lato V", + "render.lighting_falloff": "Attenuazione", + "render.lighting_falloff_help": "L'intensità è indicata alla distanza di riferimento.", + "render.lighting_height": "Altezza V", + "render.lighting_minimum": "Distanza minima", + "render.lighting_mute": "Silenzia questa luce", + "render.lighting_point": "Puntiforme", + "render.lighting_preset": "Preimpostazione dello studio", + "render.lighting_preset_cpu": "Le preimpostazioni direzionali dello studio richiedono il ray tracing su CPU. L'illuminazione GPU usa luci Sole e Area esplicite.", + "render.lighting_preset_row": "Preimpostazione", + "render.lighting_radiance": "Radianza", + "render.lighting_reference": "Distanza di riferimento", + "render.lighting_review_help": "Sostituisce l'elenco delle luci direzionali. Mantiene le luci puntiformi e ad area, l'ambiente, le ombre, la camera e tutte le altre impostazioni dell'inquadratura.", + "render.lighting_review_title": "Esamina la preimpostazione di illuminazione", + "render.lighting_samples_cpu": "I campioni delle luci ad area si applicano al ray tracing su CPU.", + "render.lighting_samples_need_area": "Aggiungi una luce ad area CPU per impostare i campioni delle luci ad area.", + "render.lighting_samples_reason": "I campioni delle luci ad area devono essere 1, 4, 16 o 64.", + "render.lighting_shadow_cpu": "L'interruttore delle ombre richiede il ray tracing su CPU. Il path tracing su GPU proietta sempre le ombre.", + "render.lighting_shadows": "Proietta ombre", + "render.lighting_shadows_section": "Ombre e campionamento", + "render.lighting_shape": "Forma", + "render.lighting_solo": "Isola questa luce", + "render.lighting_source": "Sorgente", + "render.lighting_source_section": "Sorgente luminosa", + "render.lighting_spot_preserved": "Questa luce puntiforme mantiene il cono dello spot salvato finché non se ne cambia il tipo.", + "render.lighting_sun_distance": "La direzione del sole non dipende dalla distanza né dal punto di mira.", + "render.lighting_unsupported": "non supportato", + "render.lighting_used": "{used} di {limit}", + "render.lighting_width": "Larghezza U", + "render.lighting_world_position": "X Y Z globali", "render.lights_fixed_to": "Luci fissate a", "render.linear_help": "Mantiene un master RGBA float a 32 bit prima di riduzione del rumore, esposizione, tone mapping, filtri e sfondo. sRGB lineare/D65; con alfa premoltiplicato dalla copertura. La memoria aggiuntiva viene ammessa esplicitamente. Nessuna compressione né passate aggiuntive.", "render.linear_retain": "Conserva EXR lineare grezzo", "render.linear_unavailable": "L'immagine completata selezionata non ha un EXR conservato. Abilita la conservazione prima del rendering; un PNG precedente non può recuperare la radianza grezza.", "render.look_add": "Aggiungi look cromatico", + "render.look_applied": "{name} applicato", "render.look_bleach": "Ispirato al bleach bypass", "render.look_cool": "Freddo", "render.look_cyanotype": "Ispirato alla cianotipia", "render.look_faded": "Stampa sbiadita", + "render.look_material": "Materiale", "render.look_mono": "Monocromatico", + "render.look_pasted": "Aspetto incollato", "render.look_sepia": "Seppia", "render.look_strength": "Intensità del look", "render.look_teal_orange": "Turchese / arancione", @@ -2598,9 +2940,11 @@ "render.output_advanced": "Avanzate", "render.output_backend_ready": "Pronto", "render.output_check": "Verifica", + "render.output_check_failed": "Verifica · errore", "render.output_check_help": "Esegui Verifica per esaminare questa richiesta senza avviare un rendering.", "render.output_check_summary": "Verifica · Note: {count}", "render.output_copy": "Copia", + "render.output_copy_report": "Copia rapporto", "render.output_destination": "Destinazione", "render.output_destination_help": "Scegli il file di destinazione quando esporti l'immagine completata.", "render.output_dimensions": "Dimensioni", @@ -2610,6 +2954,7 @@ "render.output_fit": "Adatta", "render.output_format": "Formato", "render.output_format_status": "Il PNG è l'immagine visualizzata completata, esattamente com'è. L'EXR GPU facoltativo conserva la radianza FLOAT32 lineare della scena, grezza, prima dei filtri di visualizzazione. L'esportazione non sostituisce mai la cache dell'anteprima né un lavoro diverso.", + "render.output_grading": "Correzione colore", "render.output_image": "Immagine", "render.output_largest": "max {width} × {height}", "render.output_lock_ratio": "Blocca proporzioni", @@ -2619,6 +2964,8 @@ "render.output_quality_waiting": "in attesa del contratto di qualità", "render.output_ratio_refused": "La dimensione collegata supera il limite di dimensioni di questo metodo. Viene mantenuta la dimensione precedente; sblocca le proporzioni o scegli un valore più piccolo.", "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_ok": "Dimensioni OK · {width} × {height} px", "render.output_swap_orientation": "Scambia larghezza e altezza", "render.output_tools_inspector": "Modifica dimensioni e formato nell'ispettore Uscita.", @@ -2635,6 +2982,7 @@ "render.page_output": "Uscita", "render.page_scene": "Scena", "render.paint_label": "Vernice", + "render.painting_with": "Applicazione di {name} con il pennello", "render.passive_stage": "Piano d'appoggio e scenografia passiva", "render.passive_stage_help": "I pannelli passivi finiti appartengono solo alle immagini renderizzate. Non aggiungono corpi CAD né massa. Il posizionamento è fisso dopo l'applicazione; se intersecano l'ingombro del prodotto, vengono rifiutati. La CPU non fornisce illuminazione diffusa di rimbalzo.", "render.perspective": "Camera prospettica", @@ -2656,6 +3004,7 @@ "render.pick_help": "Scegli Corpo, Faccia o Componente. Fai clic nello spazio vuoto per deselezionare; Shift o Ctrl aggiunge o commuta la selezione. Le evidenziazioni compaiono solo nella vista live del prodotto.", "render.pick_label": "Seleziona", "render.pick_up_label": "Preleva", + "render.pick_up_prompt": "Fai clic su un oggetto per prelevarne l'aspetto", "render.pick_waiting": "La selezione sul prodotto non è ancora supportata.", "render.post_contrast": "Contrasto", "render.post_gain_b": "Guadagno blu", @@ -2695,13 +3044,17 @@ "render.radial_distortion": "Distorsione radiale", "render.radial_fit": "Inquadra prodotto", "render.radial_save": "Salva inquadratura", + "render.recent_looks": "Aspetti recenti", "render.regrade_action": "Correggi il colore del master grezzo (senza riduzione del rumore)", "render.regrade_help": "Crea un PNG separato dal master grezzo completato selezionato, usando questi controlli di presentazione. Non traccia raggi, non modifica dimensioni, camera o luci acquisite e non sovrascrive l'EXR. La riduzione del rumore non viene riapplicata: i master grezzi non conservano le sue guide. Il derivato resta esportabile con gli stessi controlli del lavoro.", "render.regrade_label": "Correzione colore", "render.reload_settings": "Ricarica impostazioni salvate", + "render.remove_override": "Rimuovi sostituzione", + "render.replace_all": "Sostituisci tutto", "render.replace_ground": "Conferma la sostituzione della scenografia esistente", "render.replace_label": "Sostituisci", "render.replace_saved_surface": "Sostituisci un'associazione salvata esistente per questa destinazione", + "render.replace_with": "Sostituisci con", "render.restore_camera": "Ripristina camera salvata", "render.result_actual": "100%", "render.result_actual_help": "Un pixel dell'immagine per ogni pixel dello schermo.", @@ -2753,14 +3106,28 @@ "render.saved_surface_remove": "Rimuovi l'associazione salvata", "render.saved_surfaces": "Associazioni di superficie salvate", "render.saved_surfaces_help": "Le mappe integrate fissate seguono l'occorrenza selezionata in millimetri locali. Una destinazione sostituita o mancante è un errore, mai una riassegnazione automatica.", + "render.scene_all_shots": "Tutte le inquadrature ({count})", "render.scene_apply": "Applica lo studio esaminato", + "render.scene_batch_reason": "Tutte le inquadrature richiede la coda di rendering delle inquadrature salvate, che non è ancora disponibile.", + "render.scene_check_now": "Verifica ora", + "render.scene_check_passed": "Verifica superata · {notes} note", + "render.scene_check_stale": "Verifica non aggiornata · verifica di nuovo", + "render.scene_check_unchecked": "Non ancora verificato · 0 note", "render.scene_consent": "Conferma la sostituzione di illuminazione, ambiente, esposizione e scenografia", + "render.scene_light_summary": "{environment} · {lights} luci", + "render.scene_look_summary": "{looks} aspetti · {overrides} sostituzioni", + "render.scene_open_check": "Apri Verifica", "render.scene_preserved_fields": "Camera, campionamento, dimensioni, associazioni ottiche, geometria CAD e materiali ingegneristici vengono conservati.", "render.scene_preserves_design": "Compone l'illuminazione e un set passivo per il soggetto visibile. Geometria, materiali ingegneristici e camera restano separati.", + "render.scene_ready": "Pronto per il rendering", + "render.scene_reminder": "Scena preserva il progetto. Geometria, proprietà tecniche e CAD non vengono mai modificati da Rendering.", + "render.scene_reminders": "Promemoria", + "render.scene_renderer_ready": "{renderer} pronto", "render.scene_replaces": "Sostituisce", "render.scene_review": "Risolvi ed esamina", "render.scene_review_help": "Esamina la composizione nativa completa prima di applicarla. Le specifiche SCENE avanzate non associate non vengono sostituite.", "render.scene_scope_details": "Ambito della composizione", + "render.scene_stages": "Fasi", "render.search_appearances": "Cerca tra {count} aspetti", "render.setup_legacy_environment": "Questa impostazione precedente usa la rotazione o l'intensità dell'ambiente nel formato obsoleto. Questi valori vengono conservati; convertili esplicitamente prima di salvare un lavoro immagine completo.", "render.setup_name": "Nome dell'impostazione", @@ -2773,7 +3140,22 @@ "render.shell_tab_camera": "CAMERA RENDERING", "render.shell_tab_lighting": "ILLUMINAZIONE", "render.shell_tab_scene": "SCENA RENDERING", + "render.shot_aperture": "Apertura", + "render.shot_distance": "Distanza", + "render.shot_dof": "Profondità di campo", "render.shot_duplicate": "Duplica l'inquadratura salvata", + "render.shot_edit_output": "Modifica in Uscita", + "render.shot_exposure_reason": "L'esposizione richiede il ray tracing su CPU o il path tracing su GPU.", + "render.shot_exposure_section": "Esposizione", + "render.shot_focus_distance": "Distanza di messa a fuoco", + "render.shot_fov_reason": "Il campo visivo richiede la proiezione prospettica.", + "render.shot_frame_subject": "Inquadra il soggetto", + "render.shot_invalid_camera": "I valori della camera devono essere finiti e compresi nell'intervallo.", + "render.shot_lens": "Obiettivo", + "render.shot_lens_enable_reason": "Abilita la profondità di campo per modificare apertura e distanza di messa a fuoco.", + "render.shot_lens_gpu_reason": "La profondità di campo richiede il path tracing su GPU.", + "render.shot_lens_perspective_reason": "La profondità di campo richiede la proiezione prospettica.", + "render.shot_look_selection": "Mira alla selezione", "render.shot_manager": "Inquadrature salvate", "render.shot_new_name": "Nome della nuova inquadratura", "render.shot_remove": "Rimuovi l'inquadratura salvata", @@ -2781,6 +3163,9 @@ "render.shot_remove_title": "Rimuovere l'inquadratura salvata?", "render.shot_rename": "Rinomina l'inquadratura salvata", "render.shot_saved_only": "Salva prima la bozza attuale. Duplica e Rinomina conservano esattamente le opzioni salvate e i riferimenti alle risorse.", + "render.shot_selection_reason": "Seleziona un corpo o una faccia a cui mirare.", + "render.shot_size": "Dimensioni", + "render.shot_target": "Punto di mira", "render.shots_camera_changed": "La camera è cambiata da quando l'inquadratura è stata salvata", "render.shots_name_help": "Inserisci un nome univoco per l'inquadratura corrente.", "render.shots_no_result": "Ancora nessun rendering", @@ -3003,6 +3388,13 @@ "render.wait_pick_up": "non ancora supportato: la selezione del prodotto e il prelievo degli aspetti", "render.wait_replace": "non ancora supportato: la sostituzione di tutti gli usi di un aspetto", "render.wait_solo": "non ancora supportato: la visibilità delle luci e i controlli di isolamento", + "render.wear_applies_to": "Si applica a", + "render.wear_layer_count": "{count} strati", + "render.wear_layer_one": "{count} strato", + "render.wear_layers": "Strati", + "render.wear_no_layers": "Nessuno strato", + "render.wear_selected_layer": "Strato selezionato", + "render.wear_title": "Usura", "sample.bracket.about": "Flangia di base con flange sui bordi e un orlo, con una regola da 2 mm", "sample.bracket.name": "Staffa in lamiera", "sample.enclosure.about": "Scatola svuotata con labbro, risalti per viti, appoggio e aggancio a scatto", @@ -3036,6 +3428,7 @@ "select an axis": "seleziona un asse", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "seleziona prima un asse, uno spigolo retto o circolare, oppure una faccia cilindrica, conica o toroidale", "select bodies first": "seleziona prima dei corpi", + "select components first": "seleziona prima i componenti", "select corner points, or the two lines of one corner": "seleziona punti d'angolo, oppure le due linee di un angolo", "select one corner point, or the two lines of one corner": "seleziona un punto d'angolo, oppure le due linee di un angolo", "select sheet edges first": "seleziona prima gli spigoli della lamiera", @@ -3054,6 +3447,7 @@ "settings.double_click_components": "Doppio clic su un componente per attivarlo o, se è collegato, per modificarlo sul posto", "settings.frame_rate_details_note": "Aggiunge un grafico dei tempi degli ultimi fotogrammi con i triangoli disegnati sopra, un istogramma dei tempi dei fotogrammi e ciò che la GPU ha disegnato. Fai clic sulla frequenza nella vista per mostrarli o nasconderli.", "settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista.", + "settings.insert_lists_open_designs": "Inserisci progetto elenca per primi i progetti aperti in altre schede", "settings.measure_axis_distances": "Mostra distanze X/Y/Z", "settings.measure_axis_distances_help": "Mostra le componenti lungo gli assi globali accanto alla distanza totale quando Misura è aperto.", "settings.msaa_applies_on_restart": "L'antialiasing verrà applicato al prossimo avvio di SolidCraft", @@ -3078,6 +3472,10 @@ "shortcut.unpin": "Rimuovi fissaggio", "sketch is missing": "manca lo schizzo", "sketch.degrees_of_freedom": "{count} gradi di libertà", + "sketch.dimension_added_driven": "Lo schizzo determina già quella quota, quindi è stata aggiunta come quota di riferimento (non di controllo)", + "sketch.dimension_applied": "Quota applicata", + "sketch.dimension_applied_defined": "Quota applicata; parametro `{name}` definito", + "sketch.dimension_applied_renamed": "Quota applicata; parametro denominato `{name}`", "sketch.dof_left": "{count} gradi di libertà rimanenti", "sketch.dof_left_one": "{count} grado di libertà rimanente", "sketch.draw_curves": "Disegna come {linetype}", @@ -3147,16 +3545,20 @@ "timeline.delete_folders_tip": "Mantieni le loro lavorazioni e le cartelle annidate nella cronologia", "timeline.delete_folders_with_features_tip": "Elimina le cartelle con tutte le lavorazioni al loro interno e le cartelle annidate", "timeline.delete_many": "Elimina {count} lavorazioni selezionate", + "timeline.edited_rolled_back": "{name} è cambiato, ma si trova dopo il marcatore della cronologia e quindi non viene costruito: sposta il marcatore oltre per vedere il risultato", "timeline.feature_error": "Errore: {message}", "timeline.feature_hint": "Doppio clic per modificare · trascina per riordinare · clic destro per altro", + "timeline.feature_rolled_back": "Riavvolta: dopo il marcatore della cronologia, quindi non costruita", "timeline.feature_warning": "Avviso: {message}", "timeline.group": "Nuova cartella di lavorazioni", "timeline.move_many": "Sposta elementi della sequenza temporale", "timeline.move_to_group": "Sposta lavorazioni nella cartella", "timeline.rename_group": "Rinomina cartella di lavorazioni", "timeline.reorder_many": "Riordina lavorazioni", + "timeline.suppress_features": "Sospendi lavorazioni", "timeline.suppress_folders_tip": "Sospendi o riattiva ogni lavorazione nelle cartelle e nelle cartelle annidate", "timeline.ungroup": "Rimuovi cartelle di lavorazioni", + "timeline.unsuppress_features": "Riattiva lavorazioni", "to the face": "alla faccia", "to the next face": "fino alla faccia successiva", "toolbar.customize_note": "Scheda {tab}. Seleziona un comando per assegnargli un pulsante nella barra degli strumenti. Puoi anche trascinare una riga dal menu a discesa di un pannello sulla barra, trascinare un pulsante fuori dalla barra per rimuoverlo o usare la puntina di una riga del menu a discesa.", @@ -3228,6 +3630,10 @@ "ui.3d_mouse_offer": "Calibrare il mouse 3D corregge gli assi scambiati o invertiti e imposta velocità e sensibilità.", "ui.3d_mouse_offer_never": "Non mostrare più", "ui.3d_mouse_offer_swap": "Passa a Hidraw (driver diretto)", + "ui.3d_mouse_orbit": "Orbita", + "ui.3d_mouse_orbit_classic": "Classica", + "ui.3d_mouse_orbit_tip": "Tracciata: il mouse 3D ruota la vista attorno alla parte del modello che si trova al centro della vista all'inizio della rotazione, indicata da una sfera tenue. Classica: attorno al centro della vista", + "ui.3d_mouse_orbit_traced": "Tracciata", "ui.3d_mouse_reading": "Lettura", "ui.3d_mouse_reset_axes": "Ripristina assegnazione assi", "ui.3d_mouse_reset_view": "Ripristina vista", @@ -3264,6 +3670,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.", "ui.3d_mouse_switch_cap": "Calibra come ritorno al centro", "ui.3d_mouse_switch_trackball": "Calibra come trackball", + "ui.3d_mouse_use_direct": "Usa driver diretto", + "ui.3d_mouse_use_direct_tip": "Leggi il mouse 3D direttamente (hidraw) invece che tramite spacenavd.", "ui.3d_mouse_view_axis": "Asse della vista", "ui.3d_mouse_view_off": "Disattivato", "ui.3d_mouse_view_off_tip": "Il profilo del driver del mouse 3D disattiva questo asse, quindi il suo movimento non muove la vista. Se qui scegli il movimento di un altro asse, i due si scambiano e quel movimento smette di muovere la vista.", @@ -3570,6 +3978,8 @@ "ui.cancel": "Annulla", "ui.canvases": "Immagini di riferimento", "ui.center": "Centro", + "ui.center_arc_follows_cursor": "L'arco per centro segue la direzione del cursore", + "ui.center_arc_follows_cursor_tip": "Un arco per centro procede in senso orario o antiorario a seconda di come il cursore gira attorno al suo centro. Disattivato: sempre in senso antiorario dall'inizio.", "ui.centerline": "Linea d'asse", "ui.chain": "Catena", "ui.chamfer": "Smusso", @@ -3578,6 +3988,7 @@ "ui.circle": "Cerchio", "ui.circular_pattern": "Ripetizione circolare", "ui.clear": "Cancella", + "ui.clear_print_face": "Rimuovi faccia di stampa", "ui.clear_recent": "Cancella recenti", "ui.clear_the_selection": "Deseleziona tutto", "ui.click_selects_whole_bodies": "Il clic seleziona corpi interi", @@ -3612,6 +4023,15 @@ "ui.constant": "Costante", "ui.constraints_conflict": "Vincoli in conflitto", "ui.construction": "Costruzione", + "ui.construction.angle_hint": "Retta di costruzione: fai clic su un punto, poi digita un angolo o seleziona una direzione", + "ui.construction.bisector_hint": "Bisettrice: seleziona due linee rette che si intersecano, poi la soluzione desiderata", + "ui.construction.common_hint": "Tangente comune: seleziona due cerchi/archi, poi la soluzione desiderata", + "ui.construction.contact_hint": "Tangente: seleziona un cerchio/arco o una spline/conica aperta, poi il punto di contatto (curve a forma libera: estremi)", + "ui.construction.external_hint": "Tangente: seleziona un cerchio o un arco, un punto esterno, poi la soluzione desiderata", + "ui.construction.free_hint": "Retta di costruzione: fai clic su due punti; clic destro per ricominciare", + "ui.construction.point_hint": "Retta di costruzione: fai clic sul punto di passaggio", + "ui.construction.reference_hint": "Retta di costruzione: seleziona una linea retta, poi un punto; clic destro per cambiare il riferimento", + "ui.construction.ribbon_label": "Linee aus.", "ui.context_menu": "Menu contestuale", "ui.continuity_g0": "G0 spigolo vivo", "ui.continuity_g1": "G1 tangenza", @@ -3703,6 +4123,14 @@ "ui.end_scale": "Scala finale", "ui.entities": "Entità", "ui.equal": "Uguale", + "ui.equation_end": "Fine", + "ui.equation_explicit": "Esplicita", + "ui.equation_form": "Forma", + "ui.equation_note": "La variabile è un numero puro: sin(t) la interpreta in radianti. Un risultato senza unità è nell'unità del progetto.", + "ui.equation_parametric": "Parametrica", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Raggio equivalente", "ui.expand": "Espandi", "ui.exportDxf": "Esporta DXF…", @@ -3768,6 +4196,7 @@ "ui.heading_design": "PROGETTAZIONE", "ui.heading_draft": "SFORMO", "ui.heading_edit": "MODIFICA", + "ui.heading_equation_curve": "CURVA DA EQUAZIONE", "ui.heading_extrude": "ESTRUSIONE", "ui.heading_fillet": "RACCORDO", "ui.heading_finish_editing": "TERMINA MODIFICA", @@ -3859,6 +4288,10 @@ "ui.import_ellipsis": "Importa…", "ui.infinite": "Infinito", "ui.insert": "Inserisci", + "ui.insert_design.browse": "Sfoglia…", + "ui.insert_design.open_designs": "Progetti aperti", + "ui.insert_design.saved_file": "{name} contiene modifiche non salvate, quindi è stato inserito il file salvato. Salvalo, poi usa Aggiorna collegamenti per includere le modifiche.", + "ui.insert_design.unsaved": "Modifiche non salvate", "ui.inspect": "Esamina", "ui.intersect": "Interseca", "ui.isolate": "Isola", @@ -4002,12 +4435,29 @@ "ui.polygon": "Poligono", "ui.preferences": "Preferenze", "ui.press_a_key": "premi un tasto…", + "ui.print_face_cleared": "{body}: faccia di stampa rimossa", + "ui.print_face_input": "Faccia di stampa", + "ui.print_face_one_body": "Una faccia di stampa appartiene a un solo corpo: invia quel corpo da solo per cambiarla", + "ui.print_face_pick": "Seleziona una faccia piana di un corpo solido per renderla la faccia di stampa", + "ui.print_face_set": "{body}: faccia di stampa impostata; Invia allo slicer appoggia il corpo su di essa", "ui.profile": "Profilo", "ui.profile_plane": "Piano del profilo", "ui.profiles": "Profili", "ui.project": "Proietta", "ui.project_face_edges_sketch": "Proietta gli spigoli delle facce nei nuovi schizzi", "ui.properties": "Proprietà", + "ui.properties.copy_matrix": "Copia matrice", + "ui.properties.first_moments": "Momenti del primo ordine", + "ui.properties.inertia": "Momento d'inerzia", + "ui.properties.inertia_note": "Rispetto al centro di massa, negli assi globali (g·{unit}²)", + "ui.properties.inertia_unavailable": "Non disponibile: la mesh di un corpo non è chiusa.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Misurazione…", + "ui.properties.measuring_failed": "La misurazione si è interrotta prima del termine.", + "ui.properties.principal_axis": "Asse principale {number}", + "ui.properties.principal_moments": "Momenti principali", "ui.pull_direction": "Direzione di estrazione", "ui.quality": "Qualità", "ui.quantity": "Quantità", @@ -4053,6 +4503,7 @@ "ui.revolve": "Rivoluzione", "ui.ribbon_exit": "Esci", "ui.ribbon_labels": "Mostra il testo sotto le icone della barra multifunzione", + "ui.ribbon_menus_skip_pinned": "Escludi i comandi fissati dai menu a discesa", "ui.ribbon_pin_footer": "I comandi fissati compaiono sulla barra multifunzione. Passa sopra una riga per fissarla o rimuoverla.", "ui.ribbon_workspace": "Area di lavoro", "ui.right": "Destra", @@ -4107,7 +4558,11 @@ "ui.sketch": "Schizzo", "ui.sketch_dimension": "Quota dello schizzo", "ui.sketch_grid": "Griglia dello schizzo", + "ui.sketch_intersect_geometry": "Geometria", + "ui.sketch_intersect_projection_link": "Collegamento di proiezione", + "ui.sketch_intersect_selection_filter": "Filtro di selezione", "ui.sketch_modifiers": "Modificatori dello schizzo", + "ui.sketch_project_geometry": "Geometria", "ui.sketch_scale": "Scala schizzo", "ui.sketches": "Schizzi", "ui.slice": "Genera sezioni", @@ -4158,6 +4613,7 @@ "ui.tab_manage": "Gestisci", "ui.tab_sheet_metal": "Lamiera", "ui.tab_view": "Vista", + "ui.tab_weldments": "Saldature", "ui.tangent": "Tangente", "ui.taper_angle": "Angolo di rastremazione", "ui.tapped": "Maschiato", @@ -4173,6 +4629,7 @@ "ui.tip_angle": "Angolo della punta", "ui.to_object": "Fino all'oggetto", "ui.tool_direction": "Direzione dell'utensile", + "ui.toolbar_tabs_hidden": "Tutte le schede della barra degli strumenti sono nascoste. Fai clic su + o fai clic destro qui per mostrarne una.", "ui.top": "Superiore", "ui.top_diameter": "Diametro superiore", "ui.top_radius": "Raggio superiore", @@ -4210,6 +4667,8 @@ "ui.view_centre": "Centro della vista", "ui.viewport_status_clear": "Disattiva {name}", "ui.viewport_status_clear_hint": "Disattiva {name} · Esc", + "ui.viewport_status_edges": "{count} spigoli", + "ui.viewport_status_from": "da {direction}", "ui.viewport_status_key": "Mostra la legenda di {name}", "ui.viewport_status_minimum_radius": "Raggio minimo", "ui.viewport_status_pairs": "{count} coppie", @@ -4219,6 +4678,7 @@ "ui.visual_shaded_with_edges": "Ombreggiato con spigoli", "ui.visual_style": "Stile di visualizzazione", "ui.visual_wireframe": "Reticolo", + "ui.wheel_zoom_speed": "Velocità di zoom della rotellina del mouse", "ui.width": "Larghezza", "ui.window_system": "Sistema di finestre", "ui.window_system_auto": "Automatico", @@ -4249,7 +4709,9 @@ "update.button": "Aggiorna", "update.button_version": "Aggiorna a {version}", "update.check_automatically": "Controlla automaticamente gli aggiornamenti", + "update.check_for_updates": "Verifica aggiornamenti", "update.check_now": "Controlla ora", + "update.check_unavailable": "La verifica degli aggiornamenti non è disponibile per questa installazione.", "update.checking": "Controllo…", "update.copy": "Copia", "update.dmg_opened": "L'immagine disco è aperta e verificata: trascina SolidCraft in Applicazioni sostituendo la copia precedente, poi riavvia SolidCraft.", @@ -4263,13 +4725,18 @@ "update.installed": "SolidCraft {version} è installato. Riavvia per usarlo.", "update.installer_started": "Il programma di installazione si avvia alla chiusura di SolidCraft.", "update.later": "Ricordamelo più tardi", + "update.managed": "Aggiornamento disponibile: aggiorna SolidCraft in {manager}", "update.no_notes": "Questa versione non contiene note.", "update.package_hint": "Questa copia proviene da un {kind}. Scarica il nuovo pacchetto, poi installalo con:", "update.prefs_dev": "Questa è una build di sviluppo ({version}): non controlla gli aggiornamenti.", "update.prefs_flatpak": "Questa copia ({version}) viene aggiornata da Flatpak: esegui {command} o usa il centro software. SolidCraft non effettua controlli.", "update.prefs_label": "Aggiornamenti", + "update.prefs_managed": "Questa è la versione SolidCraft {version}, gestita da {manager}. Il controllo richiede a GitHub l'ultima versione e non invia altri dati; aggiorna SolidCraft in {manager}.", "update.prefs_note": "Questa è la versione SolidCraft {version} ({kind}). Il controllo richiede a GitHub l'ultima versione e non invia altri dati.", "update.prefs_web": "Ricarica la pagina per ottenere l'ultima versione dell'app web. Se la rete non è disponibile o l'aggiornamento non riesce a finire il download in tempo, si apre invece la versione salvata; un download in corso continua per il prossimo ricaricamento.", + "update.refresh_checking": "Ricerca della versione più recente…", + "update.refresh_fallback": "Impossibile cercare una versione più recente; aggiornamento a {version}, trovata in precedenza.", + "update.refresh_selected": "Aggiornamento a {version}.", "update.release_page": "Apri pagina della versione", "update.restart": "Riavvia ora", "update.restart_later": "Più tardi", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 772c014667..7daffce8b7 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.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は0以外の数値にしてください", "Spacing must be a non-zero number": "間隔は0以外の数値にしてください", "Specialise": "特殊操作", @@ -775,6 +778,8 @@ "at the face": "面の位置", "bom.group_children": "サブパーツを親パーツの行に含める", "browser.body_name": "ボディ{number}", + "browser.calibrate_click_a": "画像をクリックして点Aを指定するか、座標を入力してください。", + "browser.calibrate_click_b": "画像をクリックして点Bを指定するか、座標を入力してください。", "browser.calibrate_title": "尺度を設定: {name}", "browser.capture_many": "{count}個のコンポーネントが移動されましたが、位置がキャプチャされていません。", "browser.capture_note": "「位置をキャプチャ」で新しい位置を保持し、「位置を元に戻す」で以前の位置に戻します。", @@ -817,6 +822,7 @@ "cmd.appearance_pick_up": "外観を取得", "cmd.appearance_replace": "外観を置換", "cmd.assembly_bom": "部品表", + "cmd.body.print_face": "造形面", "cmd.body_rename": "ボディの名前を変更", "cmd.browser_collapse_all": "すべて折りたたむ", "cmd.browser_expand_all": "すべて展開", @@ -1101,6 +1107,16 @@ "cmd.sheet_refold": "折り戻し", "cmd.sheet_rules": "板金ルールを一覧表示", "cmd.sheet_unfold": "展開", + "cmd.sketch.construction_line": "補助直線", + "cmd.sketch.construction_line.angle": "角度指定の補助直線", + "cmd.sketch.construction_line.bisector": "角の二等分線", + "cmd.sketch.construction_line.horizontal": "水平補助直線", + "cmd.sketch.construction_line.parallel": "平行補助直線", + "cmd.sketch.construction_line.perpendicular": "直交補助直線", + "cmd.sketch.construction_line.tangent_at": "曲線上の点での接線", + "cmd.sketch.construction_line.tangent_external": "外部点を通る接線", + "cmd.sketch.construction_line.tangent_two": "共通接線", + "cmd.sketch.construction_line.vertical": "垂直補助直線", "cmd.sketch_arc3d": "3D 円弧", "cmd.sketch_arc_center_point": "中心点円弧", "cmd.sketch_arc_tangent": "正接円弧", @@ -1389,7 +1405,18 @@ "help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。", "help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。", "help.pointcloud.insert": "PLY・XYZ・PTS形式のスキャンを点群としてモデルと一緒に挿入します。計測データを参考にモデリングするときに使います。", + "help.sketch.construction_line": "2点を通る無限の補助直線を作成します。スケッチの輪郭を分割せずに作図できます。", + "help.sketch.construction_line.angle": "スケッチのX軸に対して角度を指定し、無限の補助直線を作成します。点を選び、角度を入力するか方向を選びます。", + "help.sketch.construction_line.bisector": "交差する2本のスケッチ直線を選び、2つの角の二等分線から選びます。結果は元の直線の編集に追従します。", + "help.sketch.construction_line.horizontal": "指定点を通る無限の水平補助直線を作成します。後で編集しても水平拘束が維持されます。", + "help.sketch.construction_line.parallel": "スケッチの直線を選び、平行な無限の補助直線を配置します。右クリックで参照を選び直します。", + "help.sketch.construction_line.perpendicular": "スケッチの直線を選び、直交する無限の補助直線を配置します。右クリックで参照を選び直します。", + "help.sketch.construction_line.tangent_at": "円や円弧上の点、または開いたスプラインや円錐曲線の端点に接線を作成します。曲線を選び、接点を選びます。", + "help.sketch.construction_line.tangent_external": "円または円弧、外部点、接線の解を順に選びます。プレビューには描かれた円弧上の接点のみが表示されます。", + "help.sketch.construction_line.tangent_two": "2つの円または円弧を選び、プレビューから共通接線を選びます。接点は描かれた曲線上にある必要があります。", + "help.sketch.construction_line.vertical": "指定点を通る無限の垂直補助直線を作成します。後で編集しても垂直拘束が維持されます。", "help.sketch.equation_curve": "x(t) と y(t)、または y = f(x) の式から、範囲を指定して曲線を描きます。式にはパラメーターを使え、曲線はその変更に追従します。正弦波、らせん、歯車のインボリュートなどに使います。", + "help.sketch.inspect": "開いているスケッチのレポートを開きます。残りの自由度と、完全に拘束されているかどうかを表示します。スケッチを終える前に、まだ動く部分を確認するときに使います。", "help.solid.align": "選択した立体を動かし、その点、面、平面を別の場所の点、面、平面に合わせます。距離を測らずに部品を別の部品に当てて配置するときに使います。", "help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。", "help.solid.combine": "複数の立体を結合したり、対象の立体から切り取ったり、共通する体積だけを残したりでき、工具の立体を残すこともできます。部品の結合、ある形での切り抜き、2つの立体が重なる範囲の確認に使います。", @@ -1592,6 +1619,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} 秒前", @@ -1744,6 +1775,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": "内径", @@ -1766,6 +1802,8 @@ "parts.family.set_screw": "六角穴付き止めねじ 平先", "parts.family.socket_head_cap_screw": "六角穴付きボルト", "parts.family.washer": "平座金", + "parts.holes": "穴 (省略可)", + "parts.placement_hint": "1 つ以上の穴を選ぶとそれぞれに部品を取り付けます。選ばずに OK を押すと原点に配置します", "pattern.alignment_2": "配置 2", "pattern.alignment_hint": "個数には元の図形が含まれます。中央配置ではコピーを両側に配置し、反転で偶数個の場合に多く配置する側を切り替えます。", "pattern.direction_1": "方向 1", @@ -1818,6 +1856,8 @@ "prefs.bottom_centre": "下中央", "prefs.bottom_right": "右下", "prefs.browser": "ブラウザー", + "prefs.browser_eye_left": "表示切り替えを折りたたみ矢印の隣に表示", + "prefs.browser_eye_left_note": "ブラウザーの各行の表示切り替えを、行の右端の列ではなく、以前のように折りたたみ矢印の隣に表示します。", "prefs.browser_reveal_on_hover": "ホバーで表示", "prefs.browser_show_note": "「表示」はブラウザーを常に画面に表示します。「ホバーで表示」はポインターがその位置か左端の細いタブに来るまで隠し、ポインターが中にある間やそのメニューが開いている間は表示したままにします。", "prefs.browser_shown": "表示", @@ -1868,6 +1908,8 @@ "prefs.graphics_card_restart": "SolidCraft の再起動後に有効になります。WGPU_ADAPTER_NAME がこの選択より優先されます。", "prefs.graphics_note": "速度と滑らかさのバランスを調整します。各選択肢の効果をサンプルで確認できます。", "prefs.graphics_presets_note": "プリセットは以下の設定をまとめて変更します。", + "prefs.hide_constraint_glyphs_zoomed_out": "ズームアウト時に拘束グリフを隠す", + "prefs.hide_constraint_glyphs_zoomed_out_note": "スケッチを十分にズームアウトすると、拘束グリフは表示されず画面を散らかしません。オフ: ズームに関係なく固定の画面サイズのままです。", "prefs.history": "履歴", "prefs.history_origin_bodies": "原点とボディを先頭に表示", "prefs.history_origin_bodies_note": "左側またはブラウザー下の履歴の先頭に、アクティブなコンポーネントのボディフォルダーと原点を、ブラウザーと同じ表示切り替えとメニュー付きで表示します。下部の履歴は変わりません。", @@ -1906,6 +1948,9 @@ "prefs.max_file_size_step": "STEP・インポートファイル", "prefs.max_file_size_web": "ウェブ版で開けるのは STEP・インポートファイルが 512 MB、デザインが 256 MB までです。ブラウザーのタブはそれ以上のファイルを保持できません。デスクトップ版では上限を上げられます。", "prefs.megabytes": "MB", + "prefs.menu_grid": "大きなアイコン", + "prefs.menu_list": "リスト", + "prefs.menus": "メニュー", "prefs.minutes": "分", "prefs.minutes_suffix": " min", "prefs.mouse": "マウスとトラックパッド", @@ -1925,10 +1970,16 @@ "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": "画角", "prefs.placement": "配置", "prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する", "prefs.preview_choices": "選択した設定のプレビュー", + "prefs.print_face_highlight": "造形面の強調", + "prefs.print_face_highlight_tip": "ビューでボディの造形面を色付けし輪郭を描く強さ。0 % で強調しません。", "prefs.profile_active": "使用中のプロファイル", "prefs.profile_applied": "プロファイルを適用しました。", "prefs.profile_changed": "プロファイルの保存後に設定が変更されています。「保存」でプロファイルに反映されます。", @@ -1950,6 +2001,8 @@ "prefs.quality_fastest": "最速", "prefs.quality_slowest": "最も低速", "prefs.quality_smoother": "より滑らか", + "prefs.quick_measure": "ビューの隅にクイック測定", + "prefs.quick_measure_note": "選択したものの寸法をビューの右下、履歴の上に表示します: エッジの長さ(円形のものは Ø と R)、面の面積、点の位置、2 つの項目の距離と角度、複数のエッジや面の合計。クリックでコピーします。", "prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用", "prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。", "prefs.reports_saved": "{n} 件保存済み", @@ -1963,6 +2016,8 @@ "prefs.search_empty": "一致する設定がありません", "prefs.select": "選択", "prefs.select_one_item": "項目を 1 つ選択", + "prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択", + "prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。", "prefs.selecting": "選択", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え", @@ -1974,11 +2029,14 @@ "prefs.sketch_align_points_note": "描画中、点が一つのスケッチ点と水平に、別の点と垂直に同時に揃い、点線のガイドが表示されます。クリックすると両方の整列が保持されます。オフ: 直前の点にのみ、一度に一方向だけ揃えます。", "prefs.sketch_conflict_colour": "競合するジオメトリを競合色で表示", "prefs.sketch_conflict_colour_note": "スケッチの拘束が競合している間、満たされない拘束のグリフや寸法だけでなく、その拘束が保持する曲線と点も競合色で描画します。オフ: 通常の色のままです。", + "prefs.sketch_construction_lines": "拡張補助直線ツール", "prefs.sketch_exit_view": "スケッチ終了時のビュー", "prefs.sketch_exit_view.keep": "現在のビューを維持", "prefs.sketch_exit_view.previous": "前のビューに戻る", "prefs.sketch_exit_view.unless_rotated": "回転していなければ戻る", "prefs.sketch_exit_view_note": "「スケッチを終了」、キャンセル、Esc の 2 回押しの後にビューが移る先です。", + "prefs.sketch_open_look_at": "スケッチを開いたときに正対させる", + "prefs.sketch_open_look_at_note": "オフ: スケッチの作成や編集でビューを動かしません。必要なときは「正面視」でスケッチに正対します。", "prefs.sketch_recent_snap": "最近使った中心と中点へ自動スナップ", "prefs.sketch_recent_snap_note": "作図中、直前の2つの円の中心または直線辺の中点のガイドが交わる位置にカーソルをスナップします。オフ: ガイドは表示のみで、カーソルは移動しません。", "prefs.sketch_selection": "スケッチと選択", @@ -2038,13 +2096,21 @@ "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 とキャンセルと同じ操作を行うか、スケッチを終了またはキャンセルします。ビューナビゲーターはその下に移動します。", "prefs.viewport": "ビューポート", + "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": "3 個以上の状態項目を 1 つのサマリーバッジにまとめます。無効にすると、すべての項目を従来の行に表示します。", "prefs.wheel": "ホイール", "prefs.wheel_trackpad": "ホイールとトラックパッド", "prefs.workspace": "ワークスペース", @@ -2054,6 +2120,10 @@ "properties.title": "プロパティ: {name}", "recovery.days_ago": "{count}日前", "recovery.detail": "{file} · {features}フィーチャー · 自動保存: {when}", + "recovery.discard_all": "すべて破棄", + "recovery.discard_all_detail": "この操作は元に戻せません。", + "recovery.discard_all_title": "{count} 件の自動保存を破棄しますか?", + "recovery.discard_all_title_one": "1 件の自動保存を破棄しますか?", "recovery.hours_ago": "{count}時間前", "recovery.just_now": "たった今", "recovery.min_ago": "{count}分前", @@ -2919,7 +2989,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": "サイズOK · {width} × {height} px", + "render.output_size_profile": "{width} × {height} をレンダリングするには、CPUリソースプロファイル「{profile}」を選ぶか、より小さいサイズを選んでください。", "render.output_swap_orientation": "幅と高さを交換", "render.output_tools_inspector": "サイズと形式は出力インスペクターで編集してください。", "render.output_view_image": "画像を表示", @@ -3132,6 +3204,7 @@ "render.size_custom": "カスタム…", "render.size_full_hd": "フルHD", "render.size_hd": "HD", + "render.size_needs_profile": "{size}・「{profile}」が必要", "render.size_presets": "サイズプリセット", "render.size_presets_pending": "出力エディターとともに追加予定", "render.size_preview": "プレビュー", @@ -3419,6 +3492,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ボックスの先頭にピン留め", @@ -3455,6 +3546,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": "文字はパスに沿います: 位置は曲線で決まります", @@ -3595,6 +3687,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 マウス速度", @@ -3623,6 +3717,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。", "ui.3d_mouse_switch_cap": "センターに戻るタイプとしてキャリブレーション", "ui.3d_mouse_switch_trackball": "トラックボールとしてキャリブレーション", + "ui.3d_mouse_use_direct": "直接ドライバーを使用", + "ui.3d_mouse_use_direct_tip": "spacenavd を経由せず、3Dマウスを直接 (hidraw) 読み取ります。", "ui.3d_mouse_view_axis": "ビューの軸", "ui.3d_mouse_view_off": "オフ", "ui.3d_mouse_view_off_tip": "3D マウスのドライバーのプロファイルがこの軸をオフにしているため、この軸の動きではビューは動きません。ここで他の軸の動きを選ぶと 2 つが入れ替わり、その動きではビューが動かなくなります。", @@ -3756,6 +3852,8 @@ "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": "保存", @@ -3939,6 +4037,7 @@ "ui.circle": "円", "ui.circular_pattern": "円形パターン", "ui.clear": "クリア", + "ui.clear_print_face": "造形面を解除", "ui.clear_recent": "最近使った項目をクリア", "ui.clear_the_selection": "選択をクリア", "ui.click_selects_whole_bodies": "クリックでボディ全体を選択", @@ -3973,6 +4072,15 @@ "ui.constant": "一定", "ui.constraints_conflict": "拘束が競合しています", "ui.construction": "コンストラクション", + "ui.construction.angle_hint": "補助直線: 点をクリックし、角度を入力するか方向を選択", + "ui.construction.bisector_hint": "二等分線: 交差する2直線を選び、必要な解を選択", + "ui.construction.common_hint": "共通接線: 2つの円/円弧を選び、必要な解を選択", + "ui.construction.contact_hint": "接線: 円/円弧または開いたスプライン/円錐曲線を選び、接点を選択 (自由曲線は端点のみ)", + "ui.construction.external_hint": "接線: 円または円弧、外部点、必要な解の順に選択", + "ui.construction.free_hint": "補助直線: 2点をクリック。右クリックで最初から", + "ui.construction.point_hint": "補助直線: 通過点をクリック", + "ui.construction.reference_hint": "補助直線: 直線、点の順に選択。右クリックで参照を変更", + "ui.construction.ribbon_label": "補助線", "ui.context_menu": "コンテキスト メニュー", "ui.continuity_g0": "G0 折れ", "ui.continuity_g1": "G1 接線", @@ -4362,12 +4470,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": "ファイルの単位", @@ -4376,6 +4489,11 @@ "ui.polygon": "ポリゴン", "ui.preferences": "環境設定", "ui.press_a_key": "キーを押してください…", + "ui.print_face_cleared": "{body}: 造形面を解除しました", + "ui.print_face_input": "造形面", + "ui.print_face_one_body": "造形面はボディごとに選びます。変更するにはそのボディだけを送信してください", + "ui.print_face_pick": "ソリッドボディの平面を1つ選択すると造形面になります", + "ui.print_face_set": "{body}: 造形面を設定しました。スライサーへ送るとこの面を下にして置きます", "ui.profile": "プロファイル", "ui.profile_plane": "プロファイル平面", "ui.profiles": "プロファイル", @@ -4439,6 +4557,7 @@ "ui.revolve": "回転", "ui.ribbon_exit": "終了", "ui.ribbon_labels": "リボンのアイコンの下にテキストを表示", + "ui.ribbon_menus_skip_pinned": "ピン留めしたコマンドをドロップダウンメニューに表示しない", "ui.ribbon_pin_footer": "ピン留めしたコマンドはリボンに表示されます。行にポインターを合わせて、ピン留めや解除ができます。", "ui.ribbon_workspace": "ワークスペース", "ui.right": "右側面", @@ -4457,6 +4576,7 @@ "ui.selectOther": "その他を選択…", "ui.selectTangentChain": "接線チェーンを選択", "ui.select_all": "すべて選択", + "ui.select_components_by_face": "コンポーネントを選択(面をクリックするとそのコンポーネントが選択されます)", "ui.selection": "選択", "ui.selection_modifiers": "選択修飾キー", "ui.selection_shift_add_only": "Shift+クリックで追加のみ(解除なし)", @@ -4498,6 +4618,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": "スライス", @@ -4602,10 +4727,19 @@ "ui.view_centre": "ビュー中心", "ui.viewport_status_clear": "{name}をオフにする", "ui.viewport_status_clear_hint": "{name}をオフにする · Esc", + "ui.viewport_status_edges": "{count} エッジ", + "ui.viewport_status_from": "方向 {direction}", "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": "エッジ付きシェーディング", @@ -4642,7 +4776,9 @@ "update.button": "アップデート", "update.button_version": "{version} にアップデート", "update.check_automatically": "アップデートを自動的に確認", + "update.check_for_updates": "アップデートを確認", "update.check_now": "今すぐ確認", + "update.check_unavailable": "このインストールではアップデートを確認できません。", "update.checking": "確認中…", "update.copy": "コピー", "update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 3c5986214f..ec3be2dfaf 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.body.print_face": "출력 바닥면", "cmd.body_rename": "바디 이름 변경", "cmd.browser_group": "새 그룹", "cmd.browser_move": "브라우저 항목 이동", @@ -958,6 +959,16 @@ "cmd.sheet_refold": "다시 접기", "cmd.sheet_rules": "판금 규칙 목록", "cmd.sheet_unfold": "펼치기", + "cmd.sketch.construction_line": "보조 직선", + "cmd.sketch.construction_line.angle": "각도 지정 보조 직선", + "cmd.sketch.construction_line.bisector": "각의 이등분선", + "cmd.sketch.construction_line.horizontal": "수평 보조 직선", + "cmd.sketch.construction_line.parallel": "평행 보조 직선", + "cmd.sketch.construction_line.perpendicular": "직교 보조 직선", + "cmd.sketch.construction_line.tangent_at": "곡선 위 점에서의 접선", + "cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선", + "cmd.sketch.construction_line.tangent_two": "공통 접선", + "cmd.sketch.construction_line.vertical": "수직 보조 직선", "cmd.sketch_arc_center_point": "중심점 호", "cmd.sketch_arc_tangent": "접선 호", "cmd.sketch_arc_three_point": "3점 호", @@ -1199,6 +1210,16 @@ "help.mit": "MIT 라이선스", "help.notice": "NOTICE", "help.pointcloud.insert": "PLY, XYZ 또는 PTS 스캔을 모델 옆에 포인트 클라우드로 삽입합니다. 측정 데이터를 참고하여 모델링할 때 사용합니다.", + "help.sketch.construction_line": "두 점을 지나는 무한 보조 직선을 만듭니다. 스케치 프로파일을 나누지 않고 작도할 수 있습니다.", + "help.sketch.construction_line.angle": "스케치 X축을 기준으로 지정한 각도의 무한 보조 직선을 만듭니다. 점을 선택한 뒤 각도를 입력하거나 방향을 선택합니다.", + "help.sketch.construction_line.bisector": "교차하는 두 스케치 직선을 선택한 뒤 두 각의 이등분선 중 하나를 선택합니다. 결과는 원본 직선의 편집을 따릅니다.", + "help.sketch.construction_line.horizontal": "한 점을 지나는 무한 수평 보조 직선을 만듭니다. 나중에 편집해도 수평 구속이 유지됩니다.", + "help.sketch.construction_line.parallel": "스케치 직선을 선택한 뒤 평행한 무한 보조 직선을 배치합니다. 다른 참조를 선택하려면 마우스 오른쪽 버튼을 클릭합니다.", + "help.sketch.construction_line.perpendicular": "스케치 직선을 선택한 뒤 직교하는 무한 보조 직선을 배치합니다. 다른 참조를 선택하려면 마우스 오른쪽 버튼을 클릭합니다.", + "help.sketch.construction_line.tangent_at": "원이나 원호 위의 점, 또는 열린 스플라인이나 원뿔 곡선의 끝점에 접선을 만듭니다. 곡선을 선택한 뒤 접점을 선택합니다.", + "help.sketch.construction_line.tangent_external": "원 또는 원호, 외부 점, 접선 해를 차례로 선택합니다. 미리보기에는 그려진 원호 위의 접점만 표시됩니다.", + "help.sketch.construction_line.tangent_two": "두 원 또는 원호를 선택한 뒤 미리보기에서 공통 접선을 선택합니다. 접점은 그려진 곡선 위에 있어야 합니다.", + "help.sketch.construction_line.vertical": "한 점을 지나는 무한 수직 보조 직선을 만듭니다. 나중에 편집해도 수직 구속이 유지됩니다.", "help.sketch.equation_curve": "범위에 걸쳐 수식으로 곡선을 그립니다: x(t)와 y(t), 또는 y = f(x). 수식에 매개변수를 쓸 수 있으며 곡선이 그 값을 따라갑니다. 사인파, 나선, 기어 이의 인벌류트에 사용합니다.", "help.solid.chamfer": "선택한 모서리를 지정한 거리만큼 평평하게 깎습니다. 날카로운 모서리를 다듬거나 핀 또는 나사가 쉽게 들어가도록 할 때 사용합니다.", "help.solid.extrude": "스케치 프로파일을 지정한 거리만큼 3D로 밀어내 재료를 더하거나 제거합니다. 평면 윤곽을 블록, 보스 또는 포켓으로 만들 때 사용합니다.", @@ -1505,6 +1526,8 @@ "prefs.bottom_centre": "하단 가운데", "prefs.bottom_right": "오른쪽 아래", "prefs.browser": "브라우저", + "prefs.browser_eye_left": "표시 아이콘을 접기 화살표 옆에 표시", + "prefs.browser_eye_left_note": "브라우저 행의 표시 아이콘을 행 오른쪽 끝의 열이 아니라 이전처럼 접기 화살표 옆에 표시합니다.", "prefs.built_in": "기본 제공", "prefs.button_left": "왼쪽 드래그", "prefs.button_middle": "가운데 드래그", @@ -1548,6 +1571,8 @@ "prefs.graphics": "그래픽", "prefs.graphics_note": "속도와 부드러움 중에서 선택합니다. 각 선택의 효과가 샘플에 표시됩니다.", "prefs.graphics_presets_note": "프리셋은 아래의 선택 항목을 한 번에 설정합니다.", + "prefs.hide_constraint_glyphs_zoomed_out": "축소 시 구속 조건 기호 숨기기", + "prefs.hide_constraint_glyphs_zoomed_out_note": "스케치를 충분히 축소하면 구속 조건 기호가 가려지지 않도록 숨깁니다. 끄기: 확대/축소와 관계없이 고정된 화면 크기를 유지합니다.", "prefs.history": "이력", "prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시", "prefs.history_origin_bodies_note": "왼쪽 또는 브라우저 아래의 히스토리가 활성 구성 요소의 바디 폴더와 원점으로 시작하며, 브라우저처럼 표시 아이콘과 메뉴가 있습니다. 아래쪽 히스토리는 바뀌지 않습니다.", @@ -1592,8 +1617,12 @@ "prefs.origin_plane_names": "원점 평면 이름", "prefs.origin_plane_names_note": "브라우저, 대화 상자, 목록에 표시되는 이름입니다. {top}은 XY, {front}는 XZ, {right}는 YZ이며 Z가 위쪽입니다. 명령, 스크립트, 저장된 디자인은 XY, XZ, YZ를 유지합니다.", "prefs.overall_speed": "전체 속도", + "prefs.persistent_ribbon_pressed": "활성 리본 명령 강조 표시", + "prefs.persistent_ribbon_pressed_help": "효과가 켜져 있는 동안 리본 명령을 강조 표시합니다. 끄면 이전의 중립적인 모양과 명령 도움말을 유지합니다.", "prefs.perspective_fov": "시야각", "prefs.preview_choices": "선택 항목 미리보기", + "prefs.print_face_highlight": "출력 바닥면 강조", + "prefs.print_face_highlight_tip": "뷰에서 바디의 출력 바닥면을 색칠하고 윤곽을 그리는 강도입니다. 0 %이면 강조하지 않습니다.", "prefs.project_options_dialog": "Project 옵션 대화상자", "prefs.project_options_dialog_note": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.", "prefs.quality_default": "기본값", @@ -1609,6 +1638,8 @@ "prefs.search_empty": "일치하는 설정이 없습니다", "prefs.select": "선택", "prefs.select_one_item": "항목 하나 선택", + "prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택", + "prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.", "prefs.selecting": "선택", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글", @@ -1620,6 +1651,9 @@ "prefs.sketch_align_points_note": "그리는 동안 점이 한 스케치 점과 수평으로, 다른 점과 수직으로 동시에 맞춰지고 점선 안내선이 표시됩니다. 클릭하면 두 정렬이 모두 유지됩니다. 끄기: 이전 점에만, 한 번에 한 축씩 맞춥니다.", "prefs.sketch_conflict_colour": "충돌하는 기하를 충돌 색상으로 표시", "prefs.sketch_conflict_colour_note": "스케치의 구속 조건이 충돌하는 동안 실패한 구속 조건의 글리프나 치수뿐 아니라 그 구속 조건이 잡고 있는 곡선과 점도 충돌 색상으로 그립니다. 끄기: 평소 색상을 유지합니다.", + "prefs.sketch_construction_lines": "확장 보조 직선 도구", + "prefs.sketch_open_look_at": "스케치를 열 때 정면으로 돌리기", + "prefs.sketch_open_look_at_note": "끄기: 스케치를 만들거나 편집해도 뷰가 그대로입니다. 필요할 때 '바라보기'로 스케치를 정면으로 봅니다.", "prefs.sketch_recent_snap": "최근 중심과 중점에 자동 스냅", "prefs.sketch_recent_snap_note": "그리는 동안 최근에 사용한 두 원의 중심 또는 직선 모서리 중점의 안내선이 만나는 위치로 커서를 스냅합니다. 끄기: 안내선은 보기만 하고 커서는 움직이지 않습니다.", "prefs.sketch_selection": "스케치 및 선택", @@ -1670,10 +1704,18 @@ "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": "대화 상자나 스케치가 열려 있는 동안 뷰 오른쪽 위의 체크와 X 표시가 대화 상자의 확인과 취소와 같은 동작을 하거나 스케치를 마치고 취소합니다. 뷰 탐색기는 그 아래로 내려갑니다.", "prefs.viewport": "뷰포트", + "prefs.viewport_analysis_status": "뷰포트 해석 상태 표시", + "prefs.viewport_analysis_status_help": "활성 해석을 뷰포트 위에 표시하고 각 항목에서 범례를 엽니다. Esc로 최신 해석을 끕니다. 끄면 모서리 범례와 이전 Esc 동작으로 돌아갑니다.", + "prefs.viewport_section_status": "뷰포트 단면 상태 표시", + "prefs.viewport_section_status_help": "뷰포트에 단면 설정을 표시합니다. Escape 키와 활성 리본 버튼으로 단면을 끕니다. 비활성화하면 이전 단면 조작을 유지합니다.", + "prefs.viewport_status_summary": "뷰포트 상태 항목 요약", + "prefs.viewport_status_summary_help": "상태 항목이 3개 이상이면 하나의 요약 배지로 표시합니다. 비활성화하면 모든 항목을 기존 행에 표시합니다.", "prefs.wheel": "휠", "prefs.wheel_trackpad": "휠 및 트랙패드", "prefs.workspace": "작업 공간", @@ -1683,6 +1725,10 @@ "properties.title": "속성: {name}", "recovery.days_ago": "{count}일 전", "recovery.detail": "{file} · 피처 {features}개 · {when} 자동 저장", + "recovery.discard_all": "모두 버리기", + "recovery.discard_all_detail": "이 작업은 되돌릴 수 없습니다.", + "recovery.discard_all_title": "자동 저장 {count}개를 버리시겠습니까?", + "recovery.discard_all_title_one": "자동 저장 1개를 버리시겠습니까?", "recovery.hours_ago": "{count}시간 전", "recovery.just_now": "방금", "recovery.min_ago": "{count}분 전", @@ -2234,7 +2280,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}와(과) {profile} CPU 리소스 프로필을 선택하거나 더 작은 크기를 선택하세요.", "render.output_size_ok": "크기 정상 · {width} × {height} px", + "render.output_size_profile": "{width} × {height}을(를) 렌더링하려면 {profile} CPU 리소스 프로필이나 더 작은 크기를 선택하세요.", "render.output_tools_inspector": "출력 검사기에서 크기와 형식을 편집하세요.", "render.output_view_image": "이미지 보기", "render.paint_label": "칠하기", @@ -2366,6 +2414,7 @@ "render.size_custom": "사용자 지정…", "render.size_full_hd": "풀 HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · {profile} 필요", "render.size_presets": "크기 프리셋", "render.size_presets_pending": "출력 편집기와 함께 제공 예정", "render.size_preview": "미리보기", @@ -2587,6 +2636,24 @@ "sheet.flange_type_base": "베이스", "sheet.flange_type_contour": "윤곽", "sheet.flange_type_edge": "모서리", + "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 상자 맨 위에 고정", @@ -2771,6 +2838,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.", "ui.3d_mouse_switch_cap": "중앙 복귀형으로 보정", "ui.3d_mouse_switch_trackball": "트랙볼로 보정", + "ui.3d_mouse_use_direct": "직접 드라이버 사용", + "ui.3d_mouse_use_direct_tip": "spacenavd를 거치지 않고 3D 마우스를 직접(hidraw) 읽습니다.", "ui.3d_mouse_view_axis": "뷰 축", "ui.3d_mouse_view_off": "끔", "ui.3d_mouse_view_off_tip": "3D 마우스 드라이버 프로필이 이 축을 끄므로, 이 축의 동작으로는 뷰가 움직이지 않습니다. 여기서 다른 축의 동작을 고르면 둘이 바뀌고, 그 동작은 더 이상 뷰를 움직이지 않습니다.", @@ -2886,6 +2955,8 @@ "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_right": "오른쪽", "ui.agent_chat_save": "저장", "ui.agent_chat_send": "보내기", @@ -3034,6 +3105,7 @@ "ui.circle": "원", "ui.circular_pattern": "원형 패턴", "ui.clear": "지우기", + "ui.clear_print_face": "출력 바닥면 해제", "ui.clear_recent": "최근 항목 지우기", "ui.clear_the_selection": "선택 해제", "ui.click_selects_whole_bodies": "클릭하면 바디 전체 선택", @@ -3068,6 +3140,15 @@ "ui.constant": "상수", "ui.constraints_conflict": "구속 조건 충돌", "ui.construction": "구성선", + "ui.construction.angle_hint": "보조 직선: 점 클릭 후 각도 입력 또는 방향 선택", + "ui.construction.bisector_hint": "이등분선: 교차하는 두 직선 선택 후 원하는 해 선택", + "ui.construction.common_hint": "공통 접선: 두 원/원호 선택 후 원하는 해 선택", + "ui.construction.contact_hint": "접선: 원/원호 또는 열린 스플라인/원뿔 곡선 선택 후 접점 선택 (자유 곡선은 끝점만)", + "ui.construction.external_hint": "접선: 원 또는 원호, 외부 점, 원하는 해를 차례로 선택", + "ui.construction.free_hint": "보조 직선: 두 점 클릭; 마우스 오른쪽 버튼으로 다시 시작", + "ui.construction.point_hint": "보조 직선: 통과할 점 클릭", + "ui.construction.reference_hint": "보조 직선: 직선 선택 후 점 선택; 마우스 오른쪽 버튼으로 참조 변경", + "ui.construction.ribbon_label": "보조선", "ui.context_menu": "컨텍스트 메뉴", "ui.continuity_g0": "G0 꺾임", "ui.continuity_g1": "G1 접선", @@ -3439,6 +3520,11 @@ "ui.polygon": "다각형", "ui.preferences": "환경 설정", "ui.press_a_key": "키를 누르세요…", + "ui.print_face_cleared": "{body}: 출력 바닥면을 해제했습니다", + "ui.print_face_input": "출력 바닥면", + "ui.print_face_one_body": "출력 바닥면은 바디마다 하나입니다. 변경하려면 그 바디만 보내십시오", + "ui.print_face_pick": "솔리드 바디의 평면 하나를 선택하면 출력 바닥면이 됩니다", + "ui.print_face_set": "{body}: 출력 바닥면을 설정했습니다. 슬라이서로 보내면 이 면이 아래로 놓입니다", "ui.profile": "프로파일", "ui.profile_plane": "프로파일 평면", "ui.profiles": "프로파일", @@ -3515,6 +3601,7 @@ "ui.selectOther": "기타 선택…", "ui.selectTangentChain": "접선 체인 선택", "ui.select_all": "모두 선택", + "ui.select_components_by_face": "구성요소 선택(면을 클릭하면 해당 구성요소 선택)", "ui.selection": "선택", "ui.selection_modifiers": "선택 보조 키", "ui.selection_shift_add_only": "Shift+클릭은 추가만 함", @@ -3643,10 +3730,19 @@ "ui.view_centre": "뷰 중심", "ui.viewport_status_clear": "{name} 끄기", "ui.viewport_status_clear_hint": "{name} 끄기 · Esc", + "ui.viewport_status_edges": "모서리 {count}개", + "ui.viewport_status_from": "{direction} 방향에서", "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": "음영 처리 및 모서리", @@ -3678,7 +3774,9 @@ "update.button": "업데이트", "update.button_version": "{version}(으)로 업데이트", "update.check_automatically": "업데이트 자동 확인", + "update.check_for_updates": "업데이트 확인", "update.check_now": "지금 확인", + "update.check_unavailable": "이 설치에서는 업데이트를 확인할 수 없습니다.", "update.checking": "확인 중…", "update.copy": "복사", "update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.", diff --git a/assets/locales/ms.json b/assets/locales/ms.json index 97aa6a3fe1..c201d5aa50 100644 --- a/assets/locales/ms.json +++ b/assets/locales/ms.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Reka bentuk dibuka atau disimpan ada di sini.", "Determined by To Object. Choose Angle to edit the angular extent.": "Ditentukan oleh tetapan Ke Objek. Pilih Sudut untuk mengubah julat sudut.", "Diameter": "Diameter", + "Did this assembly open the wrong way up?": "Adakah pemasangan ini dibuka dengan orientasi yang salah?", + "Did this part open the wrong way up?": "Adakah bahagian ini dibuka dengan orientasi yang salah?", "Dimension new shapes": "Dimensikan bentuk baharu", "Direction": "Arah", "Discard": "Buang", @@ -204,6 +206,7 @@ "Edges": "Tepi", "Edit": "Sunting", "Edit Profile Sketch": "Sunting Lakaran Profil", + "Edit shortcuts": "Sunting pintasan", "Enable rule": "Aktifkan peraturan", "Enabled": "Diaktifkan", "End Radius": "Jejari Akhir", @@ -466,6 +469,7 @@ "Radius": "Jejari", "Radius Fit": "Padanan Jejari", "Ratio": "Nisbah", + "Read-only list: shortcuts can't be changed here.": "Senarai baca sahaja: pintasan tidak boleh diubah di sini.", "Reattach selected face": "Paut semula muka dipilih", "Recover": "Pulihkan", "Recover unsaved design?": "Pulihkan reka bentuk belum disimpan?", @@ -599,6 +603,7 @@ "Square": "Segi empat sama", "Stand it up": "Tegakkan", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Tegakkan: suku putaran pada X, satu ciri Alih yang boleh dibatalkan.", + "Stand this assembly up?": "Tegakkan pemasangan ini?", "Stand this part up?": "Tegakkan bahagian ini?", "Start angle": "Sudut mula", "Start bulge": "Bonjolan permulaan", @@ -649,6 +654,7 @@ "Third point": "Titik ketiga", "This child is shared. Deleting it removes it from every copy of its parent.": "Komponen anak dikongsi. Jika dipadam, semua salinan induk turut kehilangannya.", "This child is shared. Position changes apply to every copy of its parent.": "Komponen anak dikongsi. Kedudukan berubah pada semua salinan induk.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Komponen ini berada dalam subpemasangan yang diletakkan lebih daripada sekali: menjajarkannya akan mengalihkannya dalam setiap penempatan.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Muka ini tidak dapat kekalkan pautan. Pilih muka lain atau gunakan satah tetap.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Imej ini belum dilampirkan pada GitHub. Salin atau simpannya, kemudian lampirkan di GitHub.", "Thread depth": "Kedalaman bebenang", @@ -769,6 +775,8 @@ "at the face": "pada muka", "bom.group_children": "Sub-bahagian dalam baris bahagiannya", "browser.body_name": "Jasad{number}", + "browser.calibrate_click_a": "Klik imej untuk Titik A, atau taip koordinatnya.", + "browser.calibrate_click_b": "Klik imej untuk Titik B, atau taip koordinatnya.", "browser.calibrate_title": "Tentukur: {name}", "browser.capture_many": "{count} komponen telah dialihkan tetapi kedudukannya belum dirakam.", "browser.capture_note": "Rakam mengekalkan kedudukan baharu; Kembalikan memulihkannya.", @@ -790,6 +798,11 @@ "cam.roller": "Penggelek", "cam.roller_pick": "Penggelek (tidak wajib)", "cam.tip_pick": "Hujung (tidak wajib)", + "camera.shot_pitch": "Pic", + "camera.shot_roll": "Gelek", + "camera.shot_yaw": "Yaw", + "canvas.decal_place_hint": "Klik muka untuk meletakkan dekal", + "canvas.place_hint": "Klik satah atau muka untuk meletakkan kanvas", "choose a profile": "pilih profil", "choose the cam's and the follower's joints": "pilih sendi sesondol dan sendi pengikut", "choose the joints to couple": "pilih sendi yang hendak digandingkan", @@ -797,12 +810,16 @@ "click sketch curves": "klik lengkung lakaran", "cmd.appearance_assign": "Penampilan", "cmd.appearance_chain": "Rantaian Penampilan", + "cmd.appearance_copy": "Salin Gaya", + "cmd.appearance_favorite": "Tanda Gaya Kegemaran", "cmd.appearance_library": "Koleksi Penampilan", "cmd.appearance_list": "Senarai Penampilan", "cmd.appearance_paint": "Cat Penampilan", + "cmd.appearance_paste": "Tampal Gaya", "cmd.appearance_pick_up": "Ambil Penampilan", "cmd.appearance_replace": "Gantikan Penampilan", "cmd.assembly_bom": "Senarai Bahan", + "cmd.body.print_face": "Muka cetakan", "cmd.body_rename": "Tukar Nama Jasad", "cmd.browser_collapse_all": "Ringkaskan Semua", "cmd.browser_expand_all": "Kembangkan Semua", @@ -810,6 +827,7 @@ "cmd.browser_move": "Alih Item Pelayar", "cmd.browser_order": "Susun Item Pelayar", "cmd.browser_rename_group": "Tukar Nama Kumpulan", + "cmd.browser_reparent": "Alih ke Komponen", "cmd.browser_ungroup": "Nyahkumpul", "cmd.browser_visibility": "Papar/Sembunyi", "cmd.canvas_calibrate": "Tentukur Kanvas", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Sifat Bahagian", "cmd.component_metadata_set": "Tukar Sifat Bahagian", "cmd.component_move_bodies": "Alih Jasad ke Komponen", + "cmd.component_move_features": "Alih Ciri ke Komponen", "cmd.component_move_sketches": "Alih Lakaran ke Komponen", + "cmd.component_paste_from_design": "Tampal daripada Reka Bentuk Lain", "cmd.component_paste_new": "Tampal Baharu", "cmd.component_properties": "Sifat Fizikal Komponen", "cmd.component_relink": "Paut Semula Sumber", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Jejak Gerakan", "cmd.motion_trace_edit": "Sunting Jejak Gerakan", "cmd.motion_trace_keep": "Kekalkan Jejak Gerakan", + "cmd.occurrence_align": "Jajarkan Salinan", "cmd.occurrence_copy": "Tampal (Salinan)", "cmd.occurrence_drag": "Seret Salinan", "cmd.occurrence_ground": "Tetapkan", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Buang Pengikatan Permukaan", "cmd.render_library_targets": "Cari Sasaran Koleksi Render", "cmd.render_lights_add": "Tambah Lampu", + "cmd.render_lights_duplicate": "Gandakan Lampu Render", + "cmd.render_lights_list": "Senarai Lampu Render", + "cmd.render_lights_mute": "Senyapkan Lampu", + "cmd.render_lights_remove": "Buang Lampu Render", + "cmd.render_lights_set": "Tetapkan Lampu Render", "cmd.render_lights_solo": "Lampu Solo", "cmd.render_optics_bind": "Simpan Pengikatan Optik", "cmd.render_optics_unbind": "Buang Pengikatan Optik", "cmd.render_output_size": "Tetapkan Saiz Keluaran Render", "cmd.render_preflight": "Semak Permintaan Render", + "cmd.render_preflight_copy": "Salin Laporan Semakan Render", "cmd.render_preset_apply": "Gunakan Studio Asli", "cmd.render_preset_resolve": "Semak Studio Asli", "cmd.render_quality_prepare": "Semak Bajet Pensampelan Tetap", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Render Imej", "cmd.render_status": "Kemajuan Render", "cmd.select_clear": "Kosongkan Pilihan", + "cmd.settings_profile_apply": "Gunakan Profil Tetapan", + "cmd.settings_profile_delete": "Padam Profil Tetapan", + "cmd.settings_profile_export": "Eksport Profil Tetapan", + "cmd.settings_profile_import": "Import Profil Tetapan", + "cmd.settings_profile_list": "Profil Tetapan", + "cmd.settings_profile_rename": "Tukar Nama Profil Tetapan", + "cmd.settings_profile_save": "Simpan Profil Tetapan", "cmd.sheet_convert": "Tukar kepada Logam Kepingan", "cmd.sheet_export_bend_table": "Eksport Jadual Lenturan", "cmd.sheet_export_dxf": "Eksport Bentangan Rata sebagai DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Lipat Semula", "cmd.sheet_rules": "Senarai Peraturan Logam Kepingan", "cmd.sheet_unfold": "Buka Lipatan", + "cmd.sketch.construction_line": "Garis binaan", + "cmd.sketch.construction_line.angle": "Garis binaan bersudut", + "cmd.sketch.construction_line.bisector": "Pembahagi dua sudut", + "cmd.sketch.construction_line.horizontal": "Garis binaan mendatar", + "cmd.sketch.construction_line.parallel": "Garis binaan selari", + "cmd.sketch.construction_line.perpendicular": "Garis binaan serenjang", + "cmd.sketch.construction_line.tangent_at": "Tangen pada titik lengkung", + "cmd.sketch.construction_line.tangent_external": "Tangen melalui titik luar", + "cmd.sketch.construction_line.tangent_two": "Tangen sepunya", + "cmd.sketch.construction_line.vertical": "Garis binaan menegak", "cmd.sketch_arc3d": "Lengkok 3D", "cmd.sketch_arc_center_point": "Lengkok Titik Pusat", "cmd.sketch_arc_tangent": "Lengkok Tangen", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Saiz Lukisan", "cmd.sketch_edit": "Sunting Lakaran", "cmd.sketch_edit_text": "Sunting Teks", + "cmd.sketch_equation_curve": "Lengkung Persamaan", + "cmd.sketch_equation_curve_edit": "Sunting Lengkung Persamaan", "cmd.sketch_export_dxf": "Simpan sebagai DXF", "cmd.sketch_finish_auto_constrain": "Tamatkan dengan Kekangan Automatik", "cmd.sketch_fit_curves_to_section": "Padankan Lengkung pada Keratan Mesh", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "Pelayar Render", "cmd.view.render_dock": "Alih Dok Render", "cmd.view.render_focus": "Pandangan Fokus Render", + "cmd.view.status_items": "Item Status Tetingkap Pandangan", "cmd.view_ambient_occlusion": "Oklusi Ambien", "cmd.view_angle_readout": "Sudut Pandangan", "cmd.view_apply": "Pulihkan Pandangan", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Normal pada Pilihan", "cmd.view_previous_tab": "Tab Sebelumnya", "cmd.view_rename": "Tukar Nama Pandangan", + "cmd.view_render_frame": "Panduan Bingkai Render", "cmd.view_right": "Pandangan Kanan", "cmd.view_save": "Simpan Pandangan", "cmd.view_shots_strip": "Jalur Syot", @@ -1229,10 +1277,14 @@ "color.copy": "Klik untuk menyalin nilai warna", "color.edit": "Klik untuk menyunting warna", "color.gamma": "Memaparkan nilai warna dalam ruang gamma 0-255", + "color.hex": "Heks", + "color.hex_help": "Masukkan #rrggbb atau #rrggbbaa; enam digit mengekalkan kelegapan", + "color.hex_hint": "#rrggbb", "color.hue": "Rona", "color.linear": "Memaparkan nilai warna dalam ruang linear 0-1", "color.saturation_value": "Ketepuan dan nilai", "color.selected": "Warna dipilih", + "config.column_filter_empty": "Tiada lajur yang sepadan", "contact.components_count": "{count} komponen", "contact.enabled_label": "diaktifkan", "coupling.gear_1": "Gear 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Klik dimensi pemacu lain untuk menyisip namanya", "dialog.dimension_name": "Dimensi: {name}", "dialog.edit_title": "SUNTING {title}", + "dialog.no_shown_body": "Tiada jasad dipaparkan untuk diubah: paparkan satu, atau pilih Jasad Baharu", "dialog.select": "↖ Pilih", "dialog.selected_count": "↖ {count} dipilih", "dialog.selection_guidance": "Pilih input untuk diubah. Klik objek dipilih untuk nyahpilih; Ctrl/Cmd+klik juga menogol pilihan. Semak pratonton, kemudian tekan OK.", @@ -1346,8 +1399,20 @@ "help.material.assign": "Menetapkan bahan jasad yang dipilih, seperti keluli, aluminium atau ABS. Gunakan supaya jisim dan berat sepadan dengan bahagian sebenar.", "help.mit": "Lesen MIT", "help.notice": "NOTIS", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Cipta garis binaan mendatar tak terhingga melalui satu titik. Kekangan mendatarnya kekal apabila disunting kemudian.", + "help.sketch.construction_line.parallel": "Pilih garis lakaran lurus, kemudian letakkan garis binaan tak terhingga yang selari. Klik kanan untuk memilih rujukan lain.", + "help.sketch.construction_line.perpendicular": "Pilih garis lakaran lurus, kemudian letakkan garis binaan tak terhingga yang serenjang. Klik kanan untuk memilih rujukan lain.", + "help.sketch.construction_line.tangent_at": "Cipta tangen pada satu titik pada bulatan atau lengkok bulat, atau pada titik hujung splin atau lengkung kon yang terbuka. Pilih lengkung itu, kemudian titik sentuhnya.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Padam", "hole_presets.delete_prompt": "Padam pratetap peribadi ini? Lubang sedia ada tidak berubah.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Jasad ini dalam satu komponen: {bodies}. Sendi menggerakkan satu komponen berbanding yang lain, jadi setiap bahagian perlu komponen sendiri.", "joint.rigid_group_one_component": "{count} jasad ini semuanya berada dalam satu komponen (atau dalam reka bentuk itu sendiri): gunakan {command} dahulu.", "joint.rigid_group_select_components": "Pilih dua atau lebih komponen.", + "joint_move.blocked_hint": "Semak jenis sendi (seperti Tegar), status tetap, had sendi dan nilai yang dipacu parameter.", + "joint_move.blocked_title": "Tidak dapat mengalihkan bahagian ini", + "joint_move.coarse": "Langkah putaran dekat paksi (°)", + "joint_move.distance": "Jarak dari mula", + "joint_move.done": "Selesai", + "joint_move.fine": "Langkah putaran jauh dari paksi (°)", + "joint_move.fixed": "Bahagian ini tiada paksi sendi yang boleh digerakkan.", + "joint_move.help": "Alih — seret anak panah atau gelang. Esc membatalkan seretan semasa.", + "joint_move.legacy_hint": "Matikan untuk menggunakan panel Alih sebelumnya.", + "joint_move.precision": "Tarik ke luar panduan putus-putus untuk putaran halus.", + "joint_move.rotation": "Putaran dari mula", + "joint_move.select": "Pilih muka atau satu bahagian pemasangan, kemudian pilih Alih.", + "joint_move.settings": "Alih Pemasangan", + "joint_move.step": "Langkah", + "joint_move.use_handles": "Gunakan pemegang gerakan sendi untuk Alih pemasangan", "keymap.command_id": "ID Arahan", "keymap.empty": "Tiada arahan yang sepadan", "keymap.use_anyway": "Guna {key} di sini juga", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+seret kiri mengorbit, Alt+seret tengah menyorot, Alt+seret kanan menzum; seret tengah juga menyorot", "nav.openscad.help": "Seret kiri pada ruang kosong mengorbit, Shift+seret kiri memilih dengan kotak, seretan geometri lakaran menyunting; seret kanan atau tengah menyorot, Shift+seret kanan atau Shift+seret tengah menzum", "nav.perspective.sketch": "Penyuntingan lakaran menggunakan pandangan ortografik. Siapkan lakaran untuk bertukar ke perspektif.", + "nav.right_drag_orbits.help": "Mati: seret kanan tidak menggerakkan kamera dan butang kanan hanya membuka menu konteks. Memilih pemetaan tetikus SolidWorks akan mematikannya.", + "nav.right_drag_orbits.label": "Seret kanan mengorbit", "nav.solidworks.help": "Seret tengah mengorbit, Ctrl+tengah menyorot, Shift+tengah menzum, seret kanan mengorbit", "nav.tinkercad.help": "Seret kanan mengorbit, seret tengah atau Shift+seret kanan menyorot", "nav.touchpad.help": "Tahan Shift dan gerakkan kursor untuk sorot; Alt/Option untuk orbit. Tidak perlu butang tetikus. Tatal menegak untuk zum.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Sesuaikan bar navigasi…", "navbar.custom.design": "Reka", "navbar.custom.duplicate": "Slot ini sudah ada pada bar.", + "navbar.custom.edge": "Tepi", + "navbar.custom.edge_bottom": "Bawah", + "navbar.custom.edge_top": "Atas", "navbar.custom.fade": "Pudar", "navbar.custom.full": "Buang satu slot untuk menambah yang lain.", "navbar.custom.hidden_design": "Disembunyikan dalam Reka", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Ketebalan", "parts.dimension.width": "Lebar", "parts.family.bearing": "Galas bebola alur dalam (sampul)", + "parts.family.button_head_cap_screw": "Skru kepala butang", "parts.family.dowel_pin": "Pin selari (dowel)", "parts.family.hex_bolt": "Bolt / skru kepala heks", "parts.family.hex_nut": "Nat heks", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Melicinkan tepi bergerigi.", "prefs.applies_on_restart": "Berkuat kuasa selepas dimulakan semula", "prefs.assistant": "Pembantu", + "prefs.auto_operation": "Pilih operasi secara automatik", + "prefs.auto_operation_note": "Tetingkap ciri memilih Cantum, Jasad Baharu atau Potong berdasarkan apa yang disentuh oleh bahan baharu, sehingga anda memilih satu. Mati: ia mengekalkan operasi yang dibukanya.", "prefs.axes": "Paksi", "prefs.axis": "Paksi", "prefs.bodies_folders": "Folder jasad", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Bawah tengah", "prefs.bottom_right": "Bawah kanan", "prefs.browser": "Pelayar", + "prefs.browser_eye_left": "Ikon mata di sebelah anak panah kembang/ringkas", + "prefs.browser_eye_left_note": "Baris pelayar memaparkan ikon mata papar/sembunyi di sebelah anak panah kembang/ringkas, seperti sebelum ini, bukannya dalam lajur di hujung kanan baris.", + "prefs.browser_reveal_on_hover": "Muncul apabila dilalui kursor", + "prefs.browser_show_note": "Dipaparkan mengekalkan pelayar pada skrin. Muncul apabila dilalui kursor menyembunyikannya sehingga penuding berada di atas tempatnya atau tab nipis di tepi kirinya, dan ia kekal terbuka selagi penuding berada di dalamnya atau menu daripadanya terbuka.", + "prefs.browser_shown": "Dipaparkan", "prefs.built_in": "Terbina dalam", "prefs.button_left": "Seret kiri", "prefs.button_middle": "Seret tengah", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Berkuat kuasa selepas SolidCraft dimulakan semula. WGPU_ADAPTER_NAME mengatasi pilihan ini.", "prefs.graphics_note": "Tukar kelajuan dengan kelicinan. Setiap pilihan menunjukkan kesannya pada contoh.", "prefs.graphics_presets_note": "Pratetap menetapkan pilihan di bawah sekali gus.", + "prefs.hide_constraint_glyphs_zoomed_out": "Sembunyikan glif kekangan apabila dizum keluar", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Glif kekangan hilang daripada pandangan apabila lakaran dizum keluar sehingga glif itu menyesakkan paparan. Mati: ia kekal pada saiz skrin tetap pada setiap tahap zum.", "prefs.history": "Sejarah", "prefs.history_origin_bodies": "Papar titik asal dan jasad di bahagian atas", "prefs.history_origin_bodies_note": "Sejarah di sebelah kiri atau di bawah pelayar bermula dengan folder Jasad bagi komponen aktif dan Titik Asal, lengkap dengan ikon mata dan menunya seperti dalam pelayar. Sejarah di bahagian bawah tidak berubah.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Padat melabuhkan panel pada tepi tetingkap. Kelutsinaran pelayar tersedia dalam Terapung. Tukar sisi Pembantu pada halaman Pembantu.", "prefs.left": "Kiri", "prefs.left_click": "Klik kiri", + "prefs.line_drag_draws_one": "Tekan dan seret melukis satu garis", + "prefs.line_drag_draws_one_note": "Dalam alat Garis, menekan, menyeret dan melepaskan melukis satu garis dari titik tekan hingga titik lepas, dan ia tamat di situ. Klik masih melukis rantaian. Mati: seretan meletakkan titik di tempat ia dilepaskan, seperti klik.", "prefs.linked_designs": "Reka bentuk terpaut", "prefs.live_input": "Input langsung", "prefs.live_preview": "Pratonton langsung", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "Fail STEP dan import", "prefs.max_file_size_web": "Aplikasi web membuka fail STEP dan import sehingga 512 MB serta reka bentuk sehingga 256 MB: tab pelayar tidak dapat memuatkan fail lebih besar. Aplikasi desktop boleh menaikkan had ini.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Ikon besar", + "prefs.menu_list": "Senarai", + "prefs.menus": "Menu", "prefs.minutes": "minit", "prefs.minutes_suffix": " min", "prefs.mouse": "Tetikus dan pad jejak", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Penempatan", "prefs.prefer_free_sketch_points": "Utamakan titik lakaran yang boleh digerakkan apabila titik bertindih", "prefs.preview_choices": "Pratonton pilihan anda", + "prefs.print_face_highlight": "Serlahan muka cetakan", + "prefs.print_face_highlight_tip": "Sejauh mana muka cetakan sesuatu jasad diwarnakan dan digaris dalam paparan. 0 % tidak menunjukkan serlahan.", + "prefs.profile_active": "Profil aktif", + "prefs.profile_applied": "Profil digunakan.", + "prefs.profile_changed": "Tetapan telah berubah sejak profil disimpan. Simpan mengekalkannya dalam profil.", + "prefs.profile_deleted": "Profil dipadam.", + "prefs.profile_export": "Eksport…", + "prefs.profile_exported": "Profil dieksport.", + "prefs.profile_imported": "Profil diimport. Pilihnya di bawah Profil aktif untuk beralih kepadanya.", + "prefs.profile_name_hint": "Nama profil, cth. Laptop", + "prefs.profile_rename": "Tukar nama", + "prefs.profile_renamed": "Nama profil ditukar.", + "prefs.profile_save": "Simpan", + "prefs.profile_saved": "Tetapan semasa disimpan ke profil.", + "prefs.profile_skipped": "Profil digunakan; {n} tetapan mengekalkan nilai semasanya (lihat log).", + "prefs.profiles": "Profil tetapan", + "prefs.profiles_note": "Pintasan, bar alat, navigasi, menu jejarian dan tetapan antara muka, disimpan bersama di bawah satu nama dan ditukar sekali gus.", + "prefs.project_options_dialog": "Dialog pilihan Unjur", + "prefs.project_options_dialog_note": "Hidup: pilih Geometri atau Jasad dan Pautan Unjuran sebelum mengunjur. Mati: kekalkan aliran kerja pilih terus sebelumnya.", "prefs.quality_default": "lalai", "prefs.quality_fastest": "terpantas", "prefs.quality_slowest": "terperlahan", "prefs.quality_smoother": "lebih licin", + "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", + "prefs.ribbon_reveal": "Tunjukkan hasil carian dalam reben", + "prefs.ribbon_reveal_note": "Melalukan kursor pada hasil dalam Cari arahan menunjukkan kedudukan arahan itu dalam reben: tabnya, butangnya atau barisnya dalam senarai juntai sesuatu panel. Arahan yang dimulakan daripada carian berkelip di situ, dan arahan yang sedang berjalan daripada senarai juntai menandakan panelnya.", "prefs.right_click": "Klik kanan", "prefs.saving": "Simpan auto dan pemulihan", "prefs.scale_note": "Juga Ctrl + / Ctrl − dalam tetingkap. Butang ialah 28 px pada 100%.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Tiada tetapan yang sepadan", "prefs.select": "Pilih", "prefs.select_one_item": "Pilih satu item", + "prefs.select_other_hidden": "Pilih geometri tersembunyi dengan Pilih Lain", + "prefs.select_other_hidden_help": "Hidup: sertakan jasad, lakaran dan tepi terlindung yang tersembunyi berhampiran penuding. Mati: guna senarai asal geometri kelihatan di bawah penuding.", "prefs.selecting": "Pemilihan", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift hanya menambah; Ctrl / Cmd menogol", "prefs.shift_add_note": "Dihidupkan: Shift menambah, Ctrl / Cmd menogol. Dimatikan (lalai): kedua-duanya menogol.", "prefs.shortcuts": "Pintasan", + "prefs.single_surface_analysis": "Gunakan satu analisis permukaan sahaja", + "prefs.single_surface_analysis_help": "Apabila dimatikan, lorekan permukaan dan Keselanjaran boleh kekal hidup bersama. Menghidupkan pilihan ini mengekalkan analisis terakhir yang dimulakan atau diubah.", + "prefs.sketch_align_points": "Selaraskan dengan titik lakaran lain semasa melukis", + "prefs.sketch_align_points_note": "Semasa melukis, titik diselaraskan secara mendatar dengan satu titik lakaran dan secara menegak dengan titik lain pada masa yang sama, dengan panduan bertitik, dan klik mengekalkan kedua-dua penjajaran. Mati: hanya dengan titik sebelumnya, satu paksi pada satu masa.", "prefs.sketch_conflict_colour": "Geometri bercanggah dalam warna percanggahan", "prefs.sketch_conflict_colour_note": "Apabila kekangan lakaran bercanggah, lengkung dan titik yang dipegang oleh kekangan yang gagal turut dilukis dalam warna percanggahan, bukan sahaja glifnya atau dimensinya. Mati: ia mengekalkan warna biasa.", + "prefs.sketch_construction_lines": "Alat garis binaan lanjutan", "prefs.sketch_exit_view": "Apabila lakaran tamat", "prefs.sketch_exit_view.keep": "Kekalkan pandangan semasa", "prefs.sketch_exit_view.previous": "Kembali ke pandangan sebelumnya", "prefs.sketch_exit_view.unless_rotated": "Kembali melainkan diputar", "prefs.sketch_exit_view_note": "Ke mana pandangan beralih selepas Siap Lakaran, Batal atau Esc dua kali.", + "prefs.sketch_open_look_at": "Putar menghadap lakaran apabila dibuka", + "prefs.sketch_open_look_at_note": "Mati: mencipta atau menyunting lakaran membiarkan pandangan seperti sedia ada. Pandang Tepat menghadapkan pandangan ke lakaran apabila anda mahu.", + "prefs.sketch_recent_snap": "Lekap auto pada pusat dan titik tengah terkini", + "prefs.sketch_recent_snap_note": "Semasa melukis, kursor melekap pada titik persilangan panduan daripada dua pusat bulatan atau titik tengah tepi lurus yang terkini. Mati: panduan itu hanya untuk paparan dan kursor tidak bergerak.", "prefs.sketch_selection": "Lakaran & pilihan", "prefs.sketch_selection_heading": "Lakaran dan pilihan", "prefs.sketch_selection_note": "Cara lakaran melekap dan cara klik memilih.", + "prefs.sketch_state_icons": "Ikon lakaran dalam warna keadaannya", + "prefs.sketch_state_icons_note": "Dalam Sejarah dan pelayar, ikon lakaran mengambil warna keadaannya (kurang terkekang, terkekang sepenuhnya atau bercanggah) dan petua alatnya menamakan keadaan itu. Mati: setiap lakaran mempunyai ikon yang sama.", "prefs.sketch_tab_always": "Kekalkan tab Lakaran dipaparkan", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Sumber", "prefs.speed": "Kelajuan", "prefs.stand_up_note": "Selepas fail STEP atau IGES dibuka dengan Z ke atas, tawarkan putaran suku pusingan pada paksi X.", + "prefs.stand_up_unknown": "Untuk fail daripada program yang tidak dikenali", + "prefs.stand_up_unknown.ask": "Tanya dalam dialog", + "prefs.stand_up_unknown.note": "Papar nota", + "prefs.stand_up_unknown.off": "Jangan tawarkan", + "prefs.stand_up_unknown_note": "Cara tawaran dibuat apabila pengepala fail tidak menamakan program yang diketahui menyimpan dengan Y ke atas. Nota dipaparkan hanya apabila fail kelihatan terbaring; Tanya dalam dialog bertanya tentang setiap fail sedemikian, seperti sebelum ini.", "prefs.status_bar": "Bar status", "prefs.status_bar_note": "Bar nipis di sepanjang tepi bawah tetingkap memaparkan apa yang sedang disunting, keadaan lakaran yang dibuka dalam warnanya dan unit reka bentuk; klik pada unit untuk menukarnya. Keadaan lakaran kemudian tidak lagi dipaparkan di sudut paparan.", "prefs.status_done": "Selesai", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Notis", "prefs.toggle_item": "Tambah item, atau keluarkannya semula", "prefs.toolbar": "Bar alat", + "prefs.toolbar_tabs": "Tab bar alat", "prefs.top": "Atas", + "prefs.trackpad_zoom_modifier": "Zum dengan tatal dua jari sambil menahan", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Tahan kekunci ini sahaja untuk zum pada penuding dengan tatal menegak. Mati mengekalkan kelakuan tatal sedia ada.", + "prefs.trackpad_zoom_modifier.off": "Mati", "prefs.translucent": "Lut sinar", "prefs.under_browser": "Di bawah pelayar", "prefs.under_cursor": "Di bawah kursor", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Tetingkap pandangan", + "prefs.viewport_analysis_status": "Papar status analisis tetingkap pandangan", + "prefs.viewport_analysis_status_help": "Papar analisis aktif di atas tetingkap pandangan, buka petunjuknya daripada setiap item, dan gunakan Esc untuk mematikan analisis terbaharu. Matikan untuk memulihkan petunjuk sudut dan tingkah laku Esc yang terdahulu.", + "prefs.viewport_background": "Latar belakang", + "prefs.viewport_background_colour": "Warna", + "prefs.viewport_background_theme": "Kecerunan tema", "prefs.wheel": "Roda", "prefs.wheel_trackpad": "Roda dan pad jejak", "prefs.workspace": "Ruang kerja", + "prefs.zoom_stop_at_model": "Hentikan zum pada model", + "prefs.zoom_stop_at_model_tip": "Tetikus 3D dan apl peranti mengezum ke arah permukaan di tengah pandangan; zum seret mengekalkan titik tempat seretan bermula. Mati: zum boleh menembusi model. Roda dan cubit menggunakan Zum ke kursor secara berasingan", "prefs.zoom_toward_cursor": "Zum ke arah kursor", "properties.title": "Sifat: {name}", "recovery.days_ago": "{count} hari lalu", "recovery.detail": "{file} · {features} ciri · disimpan automatik {when}", + "recovery.discard_all": "Buang semua", + "recovery.discard_all_detail": "Tindakan ini tidak boleh dibuat asal.", + "recovery.discard_all_title": "Buang {count} simpanan automatik?", + "recovery.discard_all_title_one": "Buang 1 simpanan automatik?", "recovery.hours_ago": "{count} j lalu", "recovery.just_now": "sebentar tadi", "recovery.min_ago": "{count} min lalu", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Pemberat", "render.appearance_physics_separate": "Penampilan visual tidak menetapkan ketumpatan, data terma atau sifat kejuruteraan lain.", "render.appearances": "Penampilan", + "render.assign_help": "Klik untuk menggunakan pada pilihan; seret untuk pratonton dan lepaskan, atau klik tanpa apa-apa dipilih untuk mengecat.", "render.availability_native": "Gubahan asli; semak sebelum menggunakan", "render.availability_projection": "Unjuran disokong; lihat skop dan perkara yang ditinggalkan", "render.availability_reference": "Data rujukan; bukan penetapan automatik", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Ambang serlahan linear", "render.browse_capabilities": "Periksa katalog keupayaan lengkap", "render.browse_environments": "Semak imbas persekitaran HDR asal", + "render.browse_looks": "Semak imbas…", "render.browse_native_scenes": "Semak imbas studio asli", "render.browse_physical": "Periksa rujukan fizikal", "render.browse_quality": "Periksa spesifikasi kualiti", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Ketelusan: permukaan legap sahaja; alfa latar belakang disokong.", "render.check_sorted_alpha": "Ketelusan: penghampiran alfa tersusun.", "render.check_transmission": "Ketelusan: transmisi fizikal.", + "render.children.absolute_floor": "Had bawah mutlak", + "render.children.adaptive": "Adaptif", + "render.children.add_look_reason": "Tambah gaya untuk menyunting status aktif dan kekuatannya.", + "render.children.all_shots": "Semua syot", + "render.children.bloom": "Bloom", + "render.children.check": "Semak", + "render.children.check_now": "Semak sekarang", + "render.children.chromatic": "Kromatik", + "render.children.compact": "Padat", + "render.children.contrast": "Kontras", + "render.children.copy_image": "Salin", + "render.children.copy_job": "Salin JSON kerja", + "render.children.copy_request": "Salin JSON permintaan", + "render.children.coverage_error": "Ralat liputan", + "render.children.cpu": "Penjejakan sinar CPU", + "render.children.denoise": "Kurangkan hingar dalam imej siap", + "render.children.depth": "Kedalaman laluan", + "render.children.distortion": "Herotan", + "render.children.edge_samples": "Sampel tepi", + "render.children.effects_banner": "Kesan ini memerlukan penjejakan laluan GPU. Membuka bahagian ini tidak pernah mengubah kaedah.", + "render.children.enable_adaptive": "Aktifkan perintis bebas untuk menyunting had adaptif.", + "render.children.enable_bloom": "Aktifkan bloom untuk menyunting kekuatan, ambang dan jejarinya.", + "render.children.finishing": "Kemasan", + "render.children.flare": "Kilau", + "render.children.fresnel": "Strata Fresnel", + "render.children.ghosts": "Cincin bayang", + "render.children.glass": "Pengangkutan kaca", + "render.children.go_to": "Pergi ke {editor}", + "render.children.grain": "Bijian", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Kekalkan induk linear mentah", + "render.children.last_result": "Terakhir: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Kesan kanta", + "render.children.look_summary": "{tone} · {count} gaya", + "render.children.looks": "Gaya kreatif", + "render.children.memory": "Memori", + "render.children.method": "Kaedah", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Memerlukan penjejakan sinar CPU. Membuka bahagian ini tidak pernah mengubah kaedah.", + "render.children.nested": "Dielektrik bersarang yang disahkan", + "render.children.not_checked": "Belum disemak", + "render.children.path": "Penjejakan laluan", + "render.children.pilots": "Sampel perintis", + "render.children.preview": "Muatan pratonton", + "render.children.progressive_reason": "Pilih Progresif untuk menyunting bilangan sampel dan benihnya.", + "render.children.radius": "Jejari", + "render.children.reflect_depth": "Kedalaman pantulan", + "render.children.reflect_samples": "Sampel pantulan", + "render.children.regrade": "Gred semula warna", + "render.children.relative_error": "Ralat relatif", + "render.children.render": "Render", + "render.children.render_shot": "Render syot", + "render.children.renderer": "Enjin render", + "render.children.renderers": "Enjin render", + "render.children.request": "Permintaan ini", + "render.children.reset_finishing": "Set semula kemasan", + "render.children.review_quality": "Semak spesifikasi kualiti…", + "render.children.rgb_gain": "Gandaan RGB", + "render.children.samples": "Sampel", + "render.children.sampling": "Pensampelan", + "render.children.saturation": "Ketepuan", + "render.children.scene_summary": "{bodies} jasad · {triangles} segi tiga", + "render.children.seed": "Benih", + "render.children.sharpening": "Penajaman", + "render.children.single_image": "Imej tunggal", + "render.children.soft_focus": "Fokus lembut", + "render.children.streak": "Alur", + "render.children.strength": "Kekuatan", + "render.children.studio": "Studio", + "render.children.threshold": "Ambang", + "render.children.tone": "Ton", + "render.children.under_hood": "Di sebalik tabir", + "render.children.vignette": "Vinyet", + "render.children.workstation": "Stesen kerja", + "render.choose_look": "Pilih gaya dalam Penampilan dahulu", "render.chromatic_offset": "Ofset tepi kromatik", "render.clear_stage": "Buang pentas yang disimpan", "render.copy_request": "Salin JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Kekuatan kilau bahagian terang", "render.flare_threshold": "Ambang kilau linear adegan", "render.fold_browser": "Ringkaskan Pelayar", + "render.frame_aspect": "Nisbah aspek", + "render.frame_centre_mark": "Tanda pusat", + "render.frame_copy_limit": "Tiada nama syot pendua yang tersedia", + "render.frame_copy_name": "{name} salinan {number}", + "render.frame_custom": "Tersuai", + "render.frame_distance_unit": " × saiz", + "render.frame_duplicate": "Gandakan", + "render.frame_exposure": "Pendedahan", + "render.frame_fov": "Medan pandangan", + "render.frame_guides": "Panduan", + "render.frame_invalid_aspect": "Pratetap nisbah aspek tidak sah", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Nama", + "render.frame_output_size": "Saiz keluaran", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Potret", "render.frame_presets": "Pratetap bingkai", "render.frame_presets_pending": "Akan hadir bersama penyunting Syot", + "render.frame_projection": "Unjuran", + "render.frame_render_shot": "Render syot ini", + "render.frame_rule_thirds": "Peraturan satu pertiga", + "render.frame_safe_area": "Kawasan selamat", + "render.frame_shot_title": "Syot · {name}", + "render.frame_square": "1:1 · Segi empat sama", + "render.frame_tall": "9:16 · Menegak", + "render.frame_thirds": "Pertiga", + "render.frame_wide": "16:9 · Skrin lebar", "render.gpu_depth": "Kedalaman laluan maksimum", "render.gpu_options_help": "Sampel, kedalaman, benih dan pendedahan disimpan bersama persediaan ini. Hanya kaedah imej yang aktif disimpan.", "render.gpu_path_failed": "Penjejak laluan GPU tidak tersedia", @@ -2360,6 +2627,7 @@ "render.heading_results": "HASIL", "render.heading_shots": "SYOT", "render.hide_shots_label": "Sembunyi jalur Syot", + "render.hover_look": "{object} · {look} · klik untuk memilih", "render.image_advanced": "Kawalan GPU lanjutan", "render.image_cancelled": "Render dibatalkan", "render.image_cancelling": "Membatalkan", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Guna penghampiran seragam tanpa kesan ruang atau lanjutan", "render.library_usable": "Unjuran boleh laku", "render.library_users": "Digunakan oleh", + "render.light_aim": "Halakan ke", "render.light_anchor_pending": "Menukar rangka koordinat lampu belum disokong lagi.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Pilih penjejakan sinar CPU atau studio GPU eksplisit untuk menyunting lampu. Lampu studio GPU lama kekal baca sahaja.", + "render.light_behaviour": "KELAKUAN", + "render.light_default_fill": "Pengisi", + "render.light_default_key": "Utama", + "render.light_default_number": "Lampu {number}", + "render.light_default_rim": "Pinggir", + "render.light_delete": "Padam", + "render.light_distance": "Jarak", + "render.light_duplicate": "Gandakan", + "render.light_elevation": "Elevasi", + "render.light_gpu_punctual": "Lampu Titik dan Lampu Sorot tersedia dengan penjejakan sinar CPU.", + "render.light_hard_shadow": "Lampu ini mempunyai bayang keras; kelembutan pemancar tidak tersedia.", + "render.light_intensity": "Keamatan", + "render.light_missing": "Lampu ini tidak lagi wujud. Pilih lampu dalam Pelayar.", + "render.light_place_highlight": "Letak mengikut serlahan", + "render.light_place_pending": "Peletakan mengikut serlahan memerlukan pemilihan lampu dalam tetingkap pandangan dan belum disokong.", + "render.light_position": "KEDUDUKAN", + "render.light_preview_help": "Paparan langsung tidak berbayang; lampu Kawasan menggunakan penghampiran sampel pusat. Render menggunakan model lampu yang lengkap.", + "render.light_product_centre": "Pusat produk", + "render.light_size": "Saiz", + "render.light_softness": "Kelembutan", + "render.light_temperature": "Suhu warna", + "render.light_temperature_help": "Anggaran rona jasad hitam; disimpan sebagai RGB linear.", + "render.light_type": "Jenis", + "render.light_type_area": "Kawasan", + "render.light_type_point": "Titik", + "render.light_type_spot": "Lampu sorot", + "render.light_type_sun": "Matahari", + "render.light_view_only": "Solo dan senyap mempengaruhi pandangan ini dan rendernya; ia tidak pernah disimpan dalam reka bentuk.", + "render.light_world_origin": "Titik asal dunia", + "render.lighting_aim_point": "Titik halaan", + "render.lighting_apply": "Gunakan pratetap", + "render.lighting_area_samples": "Sampel kawasan", + "render.lighting_budget": "Bajet", + "render.lighting_budget_help": "Bilangan adalah baca sahaja. Tambah, gandakan atau padam lampu untuk mengubahnya; kaedah menetapkan hadnya.", + "render.lighting_colour": "Warna", + "render.lighting_consent": "Gantikan lampu berarah dengan pratetap ini", + "render.lighting_custom": "Lampu tersuai", + "render.lighting_custom_help": "Memilih Tersuai menukar pratetap menjadi lampu yang boleh disunting, satu baris bagi setiap lampu dalam pepohon.", + "render.lighting_direction": "Arah X Y Z", + "render.lighting_direction_help": "Atau taip arah menuju lampu (X Y Z).", + "render.lighting_distance_unit": " × saiz", + "render.lighting_edge_u": "Vektor sisi U", + "render.lighting_edge_v": "Vektor sisi V", + "render.lighting_falloff": "Pelemahan", + "render.lighting_falloff_help": "Keamatan dinyatakan pada jarak rujukan.", + "render.lighting_height": "Ketinggian V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Senyapkan lampu ini", + "render.lighting_point": "Titik", + "render.lighting_preset": "Pratetap studio", + "render.lighting_preset_cpu": "Pratetap studio berarah memerlukan penjejakan sinar CPU. Pencahayaan GPU menggunakan lampu Matahari dan Kawasan yang eksplisit.", + "render.lighting_preset_row": "Pratetap", + "render.lighting_radiance": "Radians", + "render.lighting_reference": "Rujukan", + "render.lighting_review_help": "Menggantikan senarai lampu berarah. Mengekalkan lampu titik dan kawasan, persekitaran, bayang, kamera dan semua tetapan syot yang lain.", + "render.lighting_review_title": "Semak pratetap pencahayaan", + "render.lighting_samples_cpu": "Sampel lampu kawasan terpakai pada penjejakan sinar CPU.", + "render.lighting_samples_need_area": "Tambah lampu kawasan CPU untuk menetapkan sampel kawasan.", + "render.lighting_samples_reason": "Sampel kawasan mestilah 1, 4, 16 atau 64.", + "render.lighting_shadow_cpu": "Suis bayang memerlukan penjejakan sinar CPU. Penjejakan laluan GPU sentiasa menghasilkan bayang.", + "render.lighting_shadows": "Hasilkan bayang", + "render.lighting_shadows_section": "Bayang dan pensampelan", + "render.lighting_shape": "Bentuk", + "render.lighting_solo": "Solokan lampu ini", + "render.lighting_source": "Sumber", + "render.lighting_source_section": "Sumber cahaya", + "render.lighting_spot_preserved": "Lampu titik ini mengekalkan kon lampu sorot yang disimpan sehingga jenisnya ditukar.", + "render.lighting_sun_distance": "Arah matahari tidak bergantung pada jarak dan titik halaan.", + "render.lighting_unsupported": "tidak disokong", + "render.lighting_used": "{used} daripada {limit}", + "render.lighting_width": "Lebar U", + "render.lighting_world_position": "Dunia X Y Z", "render.lights_fixed_to": "Lampu tetap pada", "render.linear_help": "Menyimpan induk RGBA titik apungan 32-bit sebelum penyahhingaran, dedahan, pemetaan ton, penapis dan latar. sRGB linear/D65; didarab awal dengan liputan. Memori tambahan dibenarkan secara jelas. Tiada mampatan atau lintasan tambahan.", "render.linear_retain": "Kekalkan EXR linear mentah", "render.linear_unavailable": "Bingkai siap yang dipilih tidak mempunyai EXR yang dikekalkan. Aktifkan pengekalan sebelum merender; PNG lama tidak dapat memulihkan radians mentah.", "render.look_add": "Tambah gaya warna", + "render.look_applied": "{name} digunakan", "render.look_bleach": "Berilhamkan pintas peluntur", "render.look_cool": "Sejuk", "render.look_cyanotype": "Berilhamkan sianotaip", "render.look_faded": "Cetakan pudar", + "render.look_material": "Bahan", "render.look_mono": "Monokrom", + "render.look_pasted": "Gaya ditampal", "render.look_sepia": "Sepia", "render.look_strength": "Kekuatan gaya", "render.look_teal_orange": "Hijau kebiruan / oren", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Lanjutan", "render.output_backend_ready": "Sedia", "render.output_check": "Semak", + "render.output_check_failed": "Semak · ralat", "render.output_check_help": "Jalankan Semak untuk menyemak permintaan ini tanpa memulakan render.", "render.output_check_summary": "Semak · Nota: {count}", "render.output_copy": "Salin", + "render.output_copy_report": "Salin laporan", "render.output_destination": "Destinasi", "render.output_destination_help": "Pilih fail destinasi semasa mengeksport imej yang siap.", "render.output_dimensions": "Saiz", @@ -2604,6 +2952,7 @@ "render.output_fit": "Muatkan", "render.output_format": "Format", "render.output_format_status": "PNG ialah imej paparan siap yang tepat. EXR GPU pilihan menyimpan radians FLOAT32 linear adegan yang mentah sebelum penapis paparan. Eksport tidak sekali-kali menggantikan cache pratonton atau tugas yang lain.", + "render.output_grading": "Penggredan warna", "render.output_image": "Imej", "render.output_largest": "Maks {width} × {height}", "render.output_lock_ratio": "Kunci nisbah aspek", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "menunggu kontrak kualiti", "render.output_ratio_refused": "Dimensi terpaut melebihi had saiz kaedah ini. Saiz sebelumnya dikekalkan; buka kunci nisbah atau pilih nilai yang lebih kecil.", "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_ok": "Saiz OK · {width} × {height} px", "render.output_swap_orientation": "Tukar lebar dan ketinggian", "render.output_tools_inspector": "Sunting saiz dan format dalam pemeriksa Keluaran.", @@ -2629,6 +2980,7 @@ "render.page_output": "Keluaran", "render.page_scene": "Adegan", "render.paint_label": "Cat", + "render.painting_with": "Mengecat dengan {name}", "render.passive_stage": "Lantai dan pentas pasif", "render.passive_stage_help": "Panel pasif terhingga hanya tergolong dalam imej yang dirender. Ia tidak menambah jasad CAD atau jisim. Penempatan menjadi tetap selepas digunakan; sempadan produk yang bersilang akan ditolak. CPU tidak menyediakan pencahayaan pantulan resap.", "render.perspective": "Kamera perspektif", @@ -2650,6 +3002,7 @@ "render.pick_help": "Pilih Jasad, Muka atau Komponen. Klik ruang kosong untuk mengosongkan; Shift atau Ctrl menambah atau menogol. Serlahan hanya muncul dalam paparan produk langsung.", "render.pick_label": "Pilih", "render.pick_up_label": "Ambil", + "render.pick_up_prompt": "Klik objek untuk mengambil gayanya", "render.pick_waiting": "Pemilihan produk belum disokong.", "render.post_contrast": "Kontras", "render.post_gain_b": "Gandaan biru", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Herotan jejarian", "render.radial_fit": "Muatkan Produk", "render.radial_save": "Simpan Syot", + "render.recent_looks": "Gaya terkini", "render.regrade_action": "Gred semula warna induk mentah (tanpa penyahdesauan)", "render.regrade_help": "Mencipta PNG berasingan daripada induk mentah siap yang dipilih, menggunakan kawalan persembahan ini. Tidak menjejak sinar, tidak mengubah saiz/kamera/lampu yang dirakam dan tidak menulis ganti EXR. Penyahdesauan tidak dikenakan semula: induk mentah tidak menyimpan panduannya. Terbitan ini kekal boleh dieksport melalui kawalan kerja yang sama.", "render.regrade_label": "Gred semula warna", "render.reload_settings": "Muat Semula Tetapan Tersimpan", + "render.remove_override": "Buang Pengatasan", + "render.replace_all": "Ganti Semua", "render.replace_ground": "Sahkan penggantian pentas sedia ada", "render.replace_label": "Ganti", "render.replace_saved_surface": "Ganti pengikatan tersimpan sedia ada untuk sasaran ini", + "render.replace_with": "Ganti dengan", "render.restore_camera": "Pulihkan Kamera Tersimpan", "render.result_actual": "100%", "render.result_actual_help": "Satu piksel imej bagi setiap piksel paparan.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Buang pengikatan tersimpan", "render.saved_surfaces": "Pengikatan permukaan tersimpan", "render.saved_surfaces_help": "Peta terbina dalam yang disemat mengikut kejadian yang dipilih dalam milimeter tempatan. Sasaran yang diganti atau hilang ialah ralat, bukan penugasan semula automatik.", + "render.scene_all_shots": "Semua syot ({count})", "render.scene_apply": "Gunakan studio yang ditinjau", + "render.scene_batch_reason": "Semua syot memerlukan baris gilir render syot tersimpan, yang belum tersedia.", + "render.scene_check_now": "Semak sekarang", + "render.scene_check_passed": "Semakan lulus · {notes} nota", + "render.scene_check_stale": "Semakan sudah lapuk · semak semula", + "render.scene_check_unchecked": "Belum disemak · 0 nota", "render.scene_consent": "Sahkan penggantian pencahayaan, persekitaran, pendedahan dan pentas", + "render.scene_light_summary": "{environment} · {lights} lampu", + "render.scene_look_summary": "{looks} gaya · {overrides} pengatasan", + "render.scene_open_check": "Buka Semak", "render.scene_preserved_fields": "Kamera, pensampelan, dimensi, pengikatan optik, geometri CAD dan bahan kejuruteraan dikekalkan.", "render.scene_preserves_design": "Menggubah pencahayaan dan pentas pasif untuk subjek yang kelihatan. Geometri, bahan kejuruteraan dan kamera kekal berasingan.", + "render.scene_ready": "Sedia untuk merender", + "render.scene_reminder": "Adegan mengekalkan reka bentuk. Geometri, sifat kejuruteraan dan CAD tidak pernah diubah daripada Render.", + "render.scene_reminders": "Peringatan", + "render.scene_renderer_ready": "{renderer} sedia", "render.scene_replaces": "Menggantikan", "render.scene_review": "Selesaikan dan tinjau", "render.scene_review_help": "Tinjau komposisi natif yang lengkap sebelum menggunakan. Spesifikasi SCENE lanjutan yang tidak terikat tidak digantikan.", "render.scene_scope_details": "Skop komposisi", + "render.scene_stages": "Peringkat", "render.search_appearances": "Cari {count} penampilan", "render.setup_legacy_environment": "Persediaan lama ini menggunakan putaran atau keamatan persekitaran warisan. Nilai itu dikekalkan; tukarkannya secara eksplisit sebelum menyimpan kerja imej penuh.", "render.setup_name": "Nama persediaan", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "KAMERA RENDER", "render.shell_tab_lighting": "PENCAHAYAAN", "render.shell_tab_scene": "ADEGAN RENDER", + "render.shot_aperture": "Apertur", + "render.shot_distance": "Jarak", + "render.shot_dof": "Kedalaman medan", "render.shot_duplicate": "Gandakan syot tersimpan", + "render.shot_edit_output": "Sunting dalam Keluaran", + "render.shot_exposure_reason": "Pendedahan memerlukan penjejakan sinar CPU atau penjejakan laluan GPU.", + "render.shot_exposure_section": "Pendedahan", + "render.shot_focus_distance": "Jarak fokus", + "render.shot_fov_reason": "Medan pandangan memerlukan unjuran perspektif.", + "render.shot_frame_subject": "Bingkaikan subjek", + "render.shot_invalid_camera": "Nilai kamera mestilah terhingga dan dalam julat.", + "render.shot_lens": "Lensa", + "render.shot_lens_enable_reason": "Aktifkan kedalaman medan untuk menyunting apertur dan jarak fokus.", + "render.shot_lens_gpu_reason": "Kedalaman medan memerlukan penjejakan laluan GPU.", + "render.shot_lens_perspective_reason": "Kedalaman medan memerlukan unjuran perspektif.", + "render.shot_look_selection": "Halakan ke pilihan", "render.shot_manager": "Syot tersimpan", "render.shot_new_name": "Nama syot baharu", "render.shot_remove": "Buang syot tersimpan", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Buang syot tersimpan?", "render.shot_rename": "Tukar nama syot tersimpan", "render.shot_saved_only": "Simpan draf semasa dahulu. Gandakan dan tukar nama mengekalkan pilihan tersimpan dan rujukan sumber dengan tepat.", + "render.shot_selection_reason": "Pilih jasad atau muka untuk dihalakan.", + "render.shot_size": "Saiz", + "render.shot_target": "Sasaran", "render.shots_camera_changed": "Kamera berubah sejak syot ini disimpan", "render.shots_name_help": "Masukkan nama unik untuk syot semasa.", "render.shots_no_result": "Belum ada render", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "belum disokong: pemilihan produk dan pensampelan penampilan", "render.wait_replace": "belum disokong: penggantian semua penggunaan sesuatu penampilan", "render.wait_solo": "belum disokong: keterlihatan lampu dan kawalan solo", + "render.wear_applies_to": "Terpakai pada", + "render.wear_layer_count": "{count} lapisan", + "render.wear_layer_one": "{count} lapisan", + "render.wear_layers": "Lapisan", + "render.wear_no_layers": "Tiada lapisan", + "render.wear_selected_layer": "Lapisan dipilih", + "render.wear_title": "Kehausan", "sample.bracket.about": "Bebibir asas dengan bebibir tepi dan kelim mengikut peraturan 2 mm", "sample.bracket.name": "Pendakap Logam Kepingan", "sample.enclosure.about": "Kotak berongga dengan bibir, bonggol skru, penyokong dan sambungan sentap", @@ -3030,6 +3426,7 @@ "select an axis": "pilih paksi", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "pilih paksi, tepi lurus atau bulat, atau muka silinder, kon atau torus dahulu", "select bodies first": "pilih jasad dahulu", + "select components first": "pilih komponen dahulu", "select corner points, or the two lines of one corner": "pilih titik sudut, atau dua garis bagi satu sudut", "select one corner point, or the two lines of one corner": "pilih satu titik sudut, atau dua garis bagi satu sudut", "select sheet edges first": "pilih tepi kepingan dahulu", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Klik dua kali pada komponen untuk mengaktifkannya, atau untuk menyunting komponen terpaut di tempat", "settings.frame_rate_details_note": "Menambah graf masa bingkai terkini dengan segi tiga yang dilukis di atasnya, histogram masa bingkai dan apa yang dilukis oleh GPU. Klik kadar bingkai dalam paparan untuk memaparkan atau menyembunyikannya.", "settings.frame_rate_note": "Papar bingkai sesaat di kanan bawah.", + "settings.insert_lists_open_designs": "Sisip Reka Bentuk menyenaraikan reka bentuk yang terbuka dalam tab lain dahulu", "settings.measure_axis_distances": "Papar jarak X/Y/Z", "settings.measure_axis_distances_help": "Papar jarak paksi dunia X/Y/Z bersama jarak keseluruhan semasa Ukur dibuka.", "settings.msaa_applies_on_restart": "Antialias digunakan apabila SolidCraft dimulakan semula", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Nyahsemat", "sketch is missing": "lakaran tiada", "sketch.degrees_of_freedom": "{count} darjah kebebasan", + "sketch.dimension_added_driven": "Lakaran sudah menentukan dimensi itu, jadi ia ditambah sebagai dimensi dipacu (rujukan)", + "sketch.dimension_applied": "Dimensi digunakan", + "sketch.dimension_applied_defined": "Dimensi digunakan; parameter `{name}` ditakrifkan", + "sketch.dimension_applied_renamed": "Dimensi digunakan; parameter dinamakan `{name}`", "sketch.dof_left": "{count} darjah kebebasan berbaki", "sketch.dof_left_one": "{count} darjah kebebasan berbaki", "sketch.draw_curves": "Lukis sebagai {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Gunakan fail fon {path}", "solid": "pepejal", "status.editing": "Menyunting {name}", + "status.navigation": "Pemetaan tetikus: {mapping}", + "status.navigation_tip": "Cara tetikus menggerakkan pandangan. Klik untuk memilih pemetaan lain", "status.under_constrained": "Kurang terkekang ({count} darjah kebebasan)", "status.under_constrained_one": "Kurang terkekang ({count} darjah kebebasan)", "status.units_tip": "Unit reka bentuk. Klik untuk menukarnya", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Kekalkan ciri dan folder bersarangnya dalam sejarah", "timeline.delete_folders_with_features_tip": "Padam folder bersama setiap ciri di dalamnya dan folder bersarangnya", "timeline.delete_many": "Padam {count} Ciri Terpilih", + "timeline.edited_rolled_back": "{name} berubah, tetapi ia berada selepas penanda sejarah, jadi ia tidak dibina: alihkan penanda melepasinya untuk melihat hasilnya", "timeline.feature_error": "Ralat: {message}", "timeline.feature_hint": "Klik dua kali untuk menyunting · seret untuk menyusun semula · klik kanan untuk lagi", + "timeline.feature_rolled_back": "Diundur: selepas penanda sejarah, jadi tidak dibina", "timeline.feature_warning": "Amaran: {message}", "timeline.group": "Folder Ciri Baharu", "timeline.move_many": "Alih Item Garis Masa", "timeline.move_to_group": "Alih Ciri ke Folder", "timeline.rename_group": "Tukar Nama Folder Ciri", "timeline.reorder_many": "Susun Semula Ciri", + "timeline.suppress_features": "Nyahaktifkan Ciri", "timeline.suppress_folders_tip": "Nyahaktifkan atau aktifkan semula setiap ciri dalam folder dan folder bersarangnya", "timeline.ungroup": "Buang Folder Ciri", + "timeline.unsuppress_features": "Aktifkan Semula Ciri", "to the face": "ke muka", "to the next face": "ke muka seterusnya", "toolbar.customize_note": "Tab {tab}. Tandakan arahan untuk memberinya butang bar alat. Anda juga boleh menyeret baris menu lungsur panel ke bar alat, menyeret butang keluar dari bar alat untuk membuangnya, atau guna semat pada baris menu lungsur.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Menentukur tetikus 3D membetulkan paksi yang bertukar atau terbalik dan menetapkan kelajuan serta rasanya.", "ui.3d_mouse_offer_never": "Jangan tunjuk lagi", "ui.3d_mouse_offer_swap": "Tukar ke Hidraw (pemacu terus)", + "ui.3d_mouse_orbit": "Orbit", + "ui.3d_mouse_orbit_classic": "Klasik", + "ui.3d_mouse_orbit_tip": "Dijejak: tetikus 3D memutar pandangan mengelilingi bahagian model di tengah pandangan semasa putaran bermula, ditandakan dengan bola samar. Klasik: mengelilingi pusat pandangan", + "ui.3d_mouse_orbit_traced": "Dijejak", "ui.3d_mouse_reading": "Bacaan", "ui.3d_mouse_reset_axes": "Tetapkan semula penetapan paksi", "ui.3d_mouse_reset_view": "Set semula paparan", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Pusing penutup lawan arah jam, tahan, kemudian lepaskan.", "ui.3d_mouse_switch_cap": "Tentukur sebagai kembali ke tengah", "ui.3d_mouse_switch_trackball": "Tentukur sebagai bola jejak", + "ui.3d_mouse_use_direct": "Guna pemacu terus", + "ui.3d_mouse_use_direct_tip": "Baca tetikus 3D secara terus (hidraw) dan bukan melalui spacenavd.", "ui.3d_mouse_view_axis": "Paksi paparan", "ui.3d_mouse_view_off": "Mati", "ui.3d_mouse_view_off_tip": "Profil pemacu tetikus 3D mematikan paksi ini, jadi gerakannya tidak menggerakkan paparan. Memilih gerakan paksi lain di sini menukar kedua-duanya, dan gerakan itu tidak lagi menggerakkan paparan.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Batal", "ui.canvases": "Kanvas", "ui.center": "Tengah", + "ui.center_arc_follows_cursor": "Lengkok titik pusat mengikut arah kursor", + "ui.center_arc_follows_cursor_tip": "Lengkok titik pusat berjalan ikut arah jam atau lawan arah jam mengikut cara kursor mengelilingi pusatnya. Mati: sentiasa lawan arah jam dari permulaan.", "ui.centerline": "Garis tengah", "ui.chain": "Rantaian", "ui.chamfer": "Serong", @@ -3570,6 +3986,7 @@ "ui.circle": "Bulatan", "ui.circular_pattern": "Corak Membulat", "ui.clear": "Kosongkan", + "ui.clear_print_face": "Kosongkan muka cetakan", "ui.clear_recent": "Kosongkan Terkini", "ui.clear_the_selection": "Kosongkan pilihan", "ui.click_selects_whole_bodies": "Klik untuk memilih keseluruhan jasad", @@ -3604,6 +4021,15 @@ "ui.constant": "Malar", "ui.constraints_conflict": "Kekangan bercanggah", "ui.construction": "Binaan", + "ui.construction.angle_hint": "Garis binaan: klik satu titik, kemudian taip sudut atau pilih arah", + "ui.construction.bisector_hint": "Pembahagi dua sudut: pilih dua garis lurus yang bersilang, kemudian penyelesaian yang dikehendaki", + "ui.construction.common_hint": "Tangen sepunya: pilih dua bulatan/lengkok, kemudian penyelesaian yang dikehendaki", + "ui.construction.contact_hint": "Tangen: pilih bulatan/lengkok atau splin/lengkung kon terbuka, kemudian titik sentuh (lengkung bentuk bebas: titik hujung)", + "ui.construction.external_hint": "Tangen: pilih bulatan atau lengkok, satu titik luar, kemudian penyelesaian yang dikehendaki", + "ui.construction.free_hint": "Garis binaan: klik dua titik; klik kanan untuk mula semula", + "ui.construction.point_hint": "Garis binaan: klik titik yang akan dilalui", + "ui.construction.reference_hint": "Garis binaan: pilih garis lurus, kemudian satu titik; klik kanan untuk menukar rujukan", + "ui.construction.ribbon_label": "Garis bantu", "ui.context_menu": "Menu konteks", "ui.continuity_g0": "Lipatan G0", "ui.continuity_g1": "Tangen G1", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Skala Akhir", "ui.entities": "Entiti", "ui.equal": "Sama", + "ui.equation_end": "Akhir", + "ui.equation_explicit": "Eksplisit", + "ui.equation_form": "Bentuk", + "ui.equation_note": "Pemboleh ubah ialah nombor biasa: sin(t) mengambilnya dalam radian. Hasil tanpa unit adalah dalam unit reka bentuk.", + "ui.equation_parametric": "Parametrik", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Jejari Setara", "ui.expand": "Kembangkan", "ui.exportDxf": "Eksport DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "REKA", "ui.heading_draft": "SUDUT DRAF", "ui.heading_edit": "SUNTING", + "ui.heading_equation_curve": "LENGKUNG PERSAMAAN", "ui.heading_extrude": "SEMPERIT", "ui.heading_fillet": "JEJARI SUDUT", "ui.heading_finish_editing": "SELESAI SUNTING", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Import…", "ui.infinite": "Tak Terhingga", "ui.insert": "Sisip", + "ui.insert_design.browse": "Semak imbas…", + "ui.insert_design.open_designs": "Reka bentuk terbuka", + "ui.insert_design.saved_file": "{name} mempunyai perubahan yang belum disimpan, jadi fail tersimpannya disisipkan. Simpan reka bentuk itu, kemudian gunakan Kemas Kini Pautan untuk membawa masuk perubahan.", + "ui.insert_design.unsaved": "Perubahan belum disimpan", "ui.inspect": "Periksa", "ui.intersect": "Silang", "ui.isolate": "Asingkan", @@ -3994,12 +4433,29 @@ "ui.polygon": "Poligon", "ui.preferences": "Tetapan", "ui.press_a_key": "tekan kekunci…", + "ui.print_face_cleared": "{body}: muka cetakan dikosongkan", + "ui.print_face_input": "Muka cetakan", + "ui.print_face_one_body": "Muka cetakan dimiliki oleh satu jasad: hantar jasad itu sahaja untuk menukarnya", + "ui.print_face_pick": "Pilih satu muka rata pada jasad pepejal untuk menjadikannya muka cetakan", + "ui.print_face_set": "{body}: muka cetakan ditetapkan; Hantar ke penghiris meletakkan jasad di atasnya", "ui.profile": "Profil", "ui.profile_plane": "Satah Profil", "ui.profiles": "Profil", "ui.project": "Unjur", "ui.project_face_edges_sketch": "Unjur tepi muka ke dalam lakaran baharu", "ui.properties": "Sifat", + "ui.properties.copy_matrix": "Salin matriks", + "ui.properties.first_moments": "Momen pertama", + "ui.properties.inertia": "Momen inersia", + "ui.properties.inertia_note": "Terhadap pusat jisim, dalam paksi dunia (g·{unit}²)", + "ui.properties.inertia_unavailable": "Tidak tersedia: jejaring sesuatu jasad tidak tertutup.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mengukur…", + "ui.properties.measuring_failed": "Pengukuran berhenti sebelum selesai.", + "ui.properties.principal_axis": "Paksi utama {number}", + "ui.properties.principal_moments": "Momen utama", "ui.pull_direction": "Arah tarikan", "ui.quality": "Kualiti", "ui.quantity": "Kuantiti", @@ -4045,6 +4501,7 @@ "ui.revolve": "Putar", "ui.ribbon_exit": "Keluar", "ui.ribbon_labels": "Tunjukkan teks di bawah ikon reben", + "ui.ribbon_menus_skip_pinned": "Kecualikan arahan tersemat daripada menu lungsur", "ui.ribbon_pin_footer": "Arahan tersemat dipaparkan pada reben. Lalukan tetikus pada baris untuk menyemat atau menyahsemat.", "ui.ribbon_workspace": "Ruang kerja", "ui.right": "Kanan", @@ -4062,6 +4519,7 @@ "ui.select": "Pilih", "ui.selectOther": "Pilih Lain…", "ui.selectTangentChain": "Pilih Rantaian Tangen", + "ui.select_all": "Pilih Semua", "ui.selection": "Pilihan", "ui.selection_modifiers": "Pengubah suai pilihan", "ui.selection_shift_add_only": "Shift+klik menambah tanpa membuang", @@ -4098,7 +4556,11 @@ "ui.sketch": "Lakaran", "ui.sketch_dimension": "Dimensi Lakaran", "ui.sketch_grid": "Grid Lakaran", + "ui.sketch_intersect_geometry": "Geometri", + "ui.sketch_intersect_projection_link": "Pautan Unjuran", + "ui.sketch_intersect_selection_filter": "Penapis Pilihan", "ui.sketch_modifiers": "Pengubah suai lakaran", + "ui.sketch_project_geometry": "Geometri", "ui.sketch_scale": "Skala Lakaran", "ui.sketches": "Lakaran", "ui.slice": "Hiris", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Urus", "ui.tab_sheet_metal": "Logam Kepingan", "ui.tab_view": "Pandangan", + "ui.tab_weldments": "Kimpalan", "ui.tangent": "Tangen", "ui.taper_angle": "Sudut Tirus", "ui.tapped": "Berbebenang", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Sudut Hujung", "ui.to_object": "Ke Objek", "ui.tool_direction": "Arah alat", + "ui.toolbar_tabs_hidden": "Semua tab bar alat disembunyikan. Klik + atau klik kanan di sini untuk memaparkan satu tab.", "ui.top": "Atas", "ui.top_diameter": "Diameter atas", "ui.top_radius": "Jejari atas", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Pusat pandangan", "ui.viewport_status_clear": "Matikan {name}", "ui.viewport_status_clear_hint": "Matikan {name} · Esc", + "ui.viewport_status_edges": "{count} tepi", + "ui.viewport_status_from": "dari {direction}", "ui.viewport_status_key": "Papar petunjuk untuk {name}", "ui.viewport_status_minimum_radius": "Jejari minimum", "ui.viewport_status_pairs": "{count} pasangan", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Berlorek dengan tepi", "ui.visual_style": "Gaya visual", "ui.visual_wireframe": "Rangka dawai", + "ui.wheel_zoom_speed": "Kelajuan zum roda tetikus", "ui.width": "Lebar", "ui.window_system": "Sistem tetingkap", "ui.window_system_auto": "Automatik", @@ -4240,7 +4707,9 @@ "update.button": "Kemas kini", "update.button_version": "Kemas kini ke {version}", "update.check_automatically": "Semak kemas kini secara automatik", + "update.check_for_updates": "Semak kemas kini", "update.check_now": "Semak sekarang", + "update.check_unavailable": "Semakan kemas kini tidak tersedia untuk pemasangan ini.", "update.checking": "Menyemak…", "update.copy": "Salin", "update.dmg_opened": "Imej cakera telah dibuka dan disahkan: seret SolidCraft ke Applications untuk menggantikan salinan lama, kemudian mulakan semula SolidCraft.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} telah dipasang. Mulakan semula untuk menggunakannya.", "update.installer_started": "Pemasang bermula apabila SolidCraft ditutup.", "update.later": "Ingatkan saya kemudian", + "update.managed": "Kemas kini tersedia: kemas kini SolidCraft dalam {manager}", "update.no_notes": "Tiada nota untuk keluaran ini.", "update.package_hint": "Salinan ini dipasang melalui {kind}. Muat turun pakej baharu, kemudian pasang dengan:", "update.prefs_dev": "Ini ialah binaan pembangunan ({version}). Semakan kemas kini tidak dilakukan.", "update.prefs_flatpak": "Flatpak mengemas kini {version}: jalankan {command} atau guna pusat perisian. SolidCraft tidak menyemak.", "update.prefs_label": "Kemas kini", + "update.prefs_managed": "Ini ialah SolidCraft {version}, diurus oleh {manager}. Semakan hanya meminta keluaran terkini daripada GitHub dan tidak menghantar apa-apa lagi; kemas kini SolidCraft dalam {manager}.", "update.prefs_note": "SolidCraft {version} ({kind}). Semakan hanya meminta keluaran terkini dari GitHub, tanpa data lain dihantar.", "update.prefs_web": "Muat semula halaman untuk mendapatkan aplikasi web terkini. Jika rangkaian tidak tersedia atau kemas kini tidak dapat selesai dimuat turun tepat pada masanya, versi yang disimpan dibuka sebaliknya; muat turun yang sedang berjalan diteruskan untuk muat semula seterusnya.", + "update.refresh_checking": "Menyemak keluaran terbaharu…", + "update.refresh_fallback": "Tidak dapat menyemak keluaran yang lebih baharu; mengemas kini ke {version}, yang ditemui sebelum ini.", + "update.refresh_selected": "Mengemas kini ke {version}.", "update.release_page": "Buka halaman keluaran", "update.restart": "Mula semula", "update.restart_later": "Kemudian", diff --git a/assets/locales/nb.json b/assets/locales/nb.json index 163dc559b7..ab93a6f8a4 100644 --- a/assets/locales/nb.json +++ b/assets/locales/nb.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Design du åpner eller lagrer, vises her.", "Determined by To Object. Choose Angle to edit the angular extent.": "Bestemmes av Til objekt. Velg Vinkel for å endre vinkelutstrekningen.", "Diameter": "Diameter", + "Did this assembly open the wrong way up?": "Ble denne sammenstillingen åpnet feil vei opp?", + "Did this part open the wrong way up?": "Ble denne delen åpnet feil vei opp?", "Dimension new shapes": "Sett mål på nye figurer", "Direction": "Retning", "Discard": "Forkast", @@ -204,6 +206,7 @@ "Edges": "Kanter", "Edit": "Rediger", "Edit Profile Sketch": "Rediger profilskisse", + "Edit shortcuts": "Rediger snarveier", "Enable rule": "Aktiver regel", "Enabled": "Aktivert", "End Radius": "Sluttradius", @@ -466,6 +469,7 @@ "Radius": "Radius", "Radius Fit": "Radiustilpasning", "Ratio": "Forhold", + "Read-only list: shortcuts can't be changed here.": "Skrivebeskyttet liste: snarveier kan ikke endres her.", "Reattach selected face": "Knytt til valgt flate på nytt", "Recover": "Gjenopprett", "Recover unsaved design?": "Gjenopprette ulagret design?", @@ -599,6 +603,7 @@ "Square": "Kvadrat", "Stand it up": "Reis den opp", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Reis den opp: en kvart omdreining om X, som én Flytt-funksjon du kan angre.", + "Stand this assembly up?": "Reise denne sammenstillingen opp?", "Stand this part up?": "Reise denne delen opp?", "Start angle": "Startvinkel", "Start bulge": "Utbuling ved starten", @@ -649,6 +654,7 @@ "Third point": "Tredje punkt", "This child is shared. Deleting it removes it from every copy of its parent.": "Denne underkomponenten er delt. Hvis du sletter den, fjernes den fra alle kopier av den overordnede komponenten.", "This child is shared. Position changes apply to every copy of its parent.": "Denne underkomponenten er delt. Posisjonsendringer gjelder for alle kopier av den overordnede komponenten.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Denne komponenten ligger i en undersammenstilling som er plassert mer enn én gang: justering flytter den i alle plasseringene.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Denne flaten kan ikke gi en varig tilknytning. Velg en annen flate eller velg å bruke et fast plan.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Dette bildet er ikke lagt ved på GitHub ennå. Kopier eller lagre det, og legg det deretter ved på GitHub.", "Thread depth": "Gjengedybde", @@ -769,6 +775,8 @@ "at the face": "ved flaten", "bom.group_children": "Underdeler i raden til overordnet del", "browser.body_name": "Kropp{number}", + "browser.calibrate_click_a": "Klikk på bildet for punkt A, eller skriv inn koordinatene.", + "browser.calibrate_click_b": "Klikk på bildet for punkt B, eller skriv inn koordinatene.", "browser.calibrate_title": "Kalibrer: {name}", "browser.capture_many": "{count} komponenter er flyttet, men de nye posisjonene er ikke registrert.", "browser.capture_note": "Registrer posisjon beholder den nye posisjonen. Tilbakestill gjenoppretter den forrige.", @@ -790,6 +798,11 @@ "cam.roller": "Rulle", "cam.roller_pick": "Rulle (valgfritt)", "cam.tip_pick": "Spiss (valgfritt)", + "camera.shot_pitch": "Helning", + "camera.shot_roll": "Rull", + "camera.shot_yaw": "Giring", + "canvas.decal_place_hint": "Klikk på en flate for å plassere dekalen", + "canvas.place_hint": "Klikk på et plan eller en flate for å plassere lerretet", "choose a profile": "velg en profil", "choose the cam's and the follower's joints": "velg leddene til kammen og følgeren", "choose the joints to couple": "velg leddene som skal kobles", @@ -797,12 +810,16 @@ "click sketch curves": "klikk på skissekurver", "cmd.appearance_assign": "Utseende", "cmd.appearance_chain": "Utseendekjede", + "cmd.appearance_copy": "Kopier stil", + "cmd.appearance_favorite": "Favorittstil", "cmd.appearance_library": "Utseendebibliotek", "cmd.appearance_list": "Vis utseender", "cmd.appearance_paint": "Mal utseende", + "cmd.appearance_paste": "Lim inn stil", "cmd.appearance_pick_up": "Plukk opp utseende", "cmd.appearance_replace": "Erstatt utseende", "cmd.assembly_bom": "Stykkliste", + "cmd.body.print_face": "Utskriftsflate", "cmd.body_rename": "Gi nytt navn til kropp", "cmd.browser_collapse_all": "Skjul alle", "cmd.browser_expand_all": "Utvid alle", @@ -810,6 +827,7 @@ "cmd.browser_move": "Flytt elementer i modelltreet", "cmd.browser_order": "Ordne elementer i modelltreet", "cmd.browser_rename_group": "Gi nytt navn til gruppe", + "cmd.browser_reparent": "Flytt til komponent", "cmd.browser_ungroup": "Opphev gruppering", "cmd.browser_visibility": "Vis/skjul", "cmd.canvas_calibrate": "Kalibrer lerret", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Delegenskaper", "cmd.component_metadata_set": "Endre delegenskaper", "cmd.component_move_bodies": "Flytt kropper til komponent", + "cmd.component_move_features": "Flytt funksjoner til komponent", "cmd.component_move_sketches": "Flytt skisser til komponent", + "cmd.component_paste_from_design": "Lim inn fra et annet design", "cmd.component_paste_new": "Lim inn ny", "cmd.component_properties": "Fysiske egenskaper for komponent", "cmd.component_relink": "Koble kilde på nytt", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Bevegelsesspor", "cmd.motion_trace_edit": "Rediger bevegelsesspor", "cmd.motion_trace_keep": "Behold bevegelsesspor", + "cmd.occurrence_align": "Juster forekomst", "cmd.occurrence_copy": "Lim inn (forekomst)", "cmd.occurrence_drag": "Dra forekomst", "cmd.occurrence_ground": "Fest posisjon", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Fjern overflatekobling", "cmd.render_library_targets": "Finn mål i rendringsbiblioteket", "cmd.render_lights_add": "Legg til lys", + "cmd.render_lights_duplicate": "Dupliser rendringslys", + "cmd.render_lights_list": "Vis rendringslys", + "cmd.render_lights_mute": "Demp lys", + "cmd.render_lights_remove": "Fjern rendringslys", + "cmd.render_lights_set": "Angi rendringslys", "cmd.render_lights_solo": "Solo-lys", "cmd.render_optics_bind": "Lagre optisk kobling", "cmd.render_optics_unbind": "Fjern optisk kobling", "cmd.render_output_size": "Angi utdatastørrelse for rendring", "cmd.render_preflight": "Kontroller rendringsforespørsel", + "cmd.render_preflight_copy": "Kopier rapport fra rendringssjekk", "cmd.render_preset_apply": "Bruk innebygd Studio", "cmd.render_preset_resolve": "Gjennomgå innebygd Studio", "cmd.render_quality_prepare": "Gjennomgå fast samplingsbudsjett", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Rendre bilde", "cmd.render_status": "Rendringsfremdrift", "cmd.select_clear": "Tøm utvalg", + "cmd.settings_profile_apply": "Bruk innstillingsprofil", + "cmd.settings_profile_delete": "Slett innstillingsprofil", + "cmd.settings_profile_export": "Eksporter innstillingsprofil", + "cmd.settings_profile_import": "Importer innstillingsprofil", + "cmd.settings_profile_list": "Innstillingsprofiler", + "cmd.settings_profile_rename": "Gi innstillingsprofil nytt navn", + "cmd.settings_profile_save": "Lagre innstillingsprofil", "cmd.sheet_convert": "Konverter til plate", "cmd.sheet_export_bend_table": "Eksporter bøyetabell", "cmd.sheet_export_dxf": "Eksporter utbretting som DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Brett tilbake", "cmd.sheet_rules": "Vis plateregler", "cmd.sheet_unfold": "Brett ut", + "cmd.sketch.construction_line": "Hjelpelinje", + "cmd.sketch.construction_line.angle": "Hjelpelinje med vinkel", + "cmd.sketch.construction_line.bisector": "Vinkelhalveringslinje", + "cmd.sketch.construction_line.horizontal": "Vannrett hjelpelinje", + "cmd.sketch.construction_line.parallel": "Parallell hjelpelinje", + "cmd.sketch.construction_line.perpendicular": "Vinkelrett hjelpelinje", + "cmd.sketch.construction_line.tangent_at": "Tangent i kurvepunkt", + "cmd.sketch.construction_line.tangent_external": "Tangent gjennom eksternt punkt", + "cmd.sketch.construction_line.tangent_two": "Felles tangent", + "cmd.sketch.construction_line.vertical": "Loddrett hjelpelinje", "cmd.sketch_arc3d": "3D-bue", "cmd.sketch_arc_center_point": "Senterpunktbue", "cmd.sketch_arc_tangent": "Tangentbue", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Tegningsstørrelse", "cmd.sketch_edit": "Rediger skisse", "cmd.sketch_edit_text": "Rediger tekst", + "cmd.sketch_equation_curve": "Ligningskurve", + "cmd.sketch_equation_curve_edit": "Rediger ligningskurve", "cmd.sketch_export_dxf": "Lagre som DXF", "cmd.sketch_finish_auto_constrain": "Fullfør med automatiske bindinger", "cmd.sketch_fit_curves_to_section": "Tilpass kurver til mesh-snitt", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "Rendringsutforsker", "cmd.view.render_dock": "Flytt rendringspanel", "cmd.view.render_focus": "Rendringsfokusvisning", + "cmd.view.status_items": "Statuselementer i visningsområdet", "cmd.view_ambient_occlusion": "Ambient occlusion", "cmd.view_angle_readout": "Visningsvinkler", "cmd.view_apply": "Gjenopprett navngitt visning", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Vinkelrett på valg", "cmd.view_previous_tab": "Forrige fane", "cmd.view_rename": "Gi nytt navn til navngitt visning", + "cmd.view_render_frame": "Hjelpelinjer for rendringsbilderute", "cmd.view_right": "Høyrevisning", "cmd.view_save": "Ny navngitt visning", "cmd.view_shots_strip": "Opptaksstrimmel", @@ -1229,10 +1277,14 @@ "color.copy": "Klikk for å kopiere fargeverdier", "color.edit": "Klikk for å redigere farge", "color.gamma": "Viser gammakorrigerte fargeverdier i området 0–255", + "color.hex": "Hex", + "color.hex_help": "Skriv inn #rrggbb eller #rrggbbaa; seks sifre beholder opasiteten", + "color.hex_hint": "#rrggbb", "color.hue": "Kulør", "color.linear": "Viser lineære fargeverdier i området 0–1", "color.saturation_value": "Metning og lysverdi", "color.selected": "Valgt farge", + "config.column_filter_empty": "Ingen samsvarende kolonner", "contact.components_count": "{count} komponenter", "contact.enabled_label": "aktivert", "coupling.gear_1": "Tannhjul 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Klikk på et annet styrende mål for å sette inn navnet", "dialog.dimension_name": "Mål: {name}", "dialog.edit_title": "REDIGER {title}", + "dialog.no_shown_body": "Ingen synlig kropp å endre: vis en, eller velg Ny kropp", "dialog.select": "↖ Velg", "dialog.selected_count": "↖ {count} valgt", "dialog.selection_guidance": "Velg et felt for å endre det. Klikk på valgte objekter for å fjerne dem fra utvalget; Ctrl/Cmd+klikk veksler også mellom valgt og ikke valgt. Se over forhåndsvisningen, og trykk deretter OK.", @@ -1346,8 +1399,20 @@ "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.mit": "MIT-lisens", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Opprett en uendelig vannrett hjelpelinje gjennom et punkt. Den vannrette bindingen følger med ved senere endringer.", + "help.sketch.construction_line.parallel": "Velg en rett skisselinje, og plasser deretter en parallell uendelig hjelpelinje. Høyreklikk for å velge en annen referanse.", + "help.sketch.construction_line.perpendicular": "Velg en rett skisselinje, og plasser deretter en vinkelrett uendelig hjelpelinje. Høyreklikk for å velge en annen referanse.", + "help.sketch.construction_line.tangent_at": "Opprett en tangent i et punkt på en sirkel eller sirkelbue, eller i endepunktet på en åpen spline eller kjeglesnittkurve. Velg kurven, og deretter berøringspunktet.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "history.sketch_under_constrained": "Underbestemt ({count} frihetsgrader)", + "history.sketch_under_constrained_one": "Underbestemt ({count} frihetsgrad)", "hole_presets.confirm_delete": "Bekreft sletting", "hole_presets.delete": "Slett", "hole_presets.delete_prompt": "Slette denne personlige forhåndsinnstillingen? Eksisterende hull endres ikke.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Disse delene er kropper i én komponent: {bodies}. Et ledd beveger én komponent i forhold til en annen, så hver del trenger sin egen komponent.", "joint.rigid_group_one_component": "Disse {count} kroppene ligger alle i én komponent (eller i selve designet): bruk {command} først.", "joint.rigid_group_select_components": "Velg to eller flere komponenter.", + "joint_move.blocked_hint": "Kontroller leddtypen (for eksempel Stiv), om posisjonen er festet, leddgrenser og parameterstyrte verdier.", + "joint_move.blocked_title": "Kan ikke flytte denne delen", + "joint_move.coarse": "Rotasjonstrinn nær aksen (°)", + "joint_move.distance": "Avstand fra start", + "joint_move.done": "Ferdig", + "joint_move.fine": "Rotasjonstrinn langt fra aksen (°)", + "joint_move.fixed": "Denne delen har ingen bevegelig leddakse.", + "joint_move.help": "Flytt — dra en pil eller en ring. Esc avbryter gjeldende drag.", + "joint_move.legacy_hint": "Slå av for å bruke det forrige Flytt-panelet.", + "joint_move.precision": "Dra utenfor den stiplede hjelpelinjen for finrotasjon.", + "joint_move.rotation": "Rotasjon fra start", + "joint_move.select": "Velg en flate eller én del i sammenstillingen, og velg deretter Flytt.", + "joint_move.settings": "Flytting i sammenstilling", + "joint_move.step": "Trinn", + "joint_move.use_handles": "Bruk håndtak for leddbevegelse ved Flytt i sammenstillinger", "keymap.command_id": "Kommando-ID", "keymap.empty": "Ingen kommandoer samsvarer med søket", "keymap.use_anyway": "Bruk {key} her likevel", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+venstredrag roterer, Alt+midtdrag panorerer, Alt+høyredrag zoomer; midtdrag panorerer også", "nav.openscad.help": "Venstredrag i tomt område roterer, Shift+venstredrag velger med ramme, dra i skissegeometri for å redigere; høyre- eller midtdrag panorerer, Shift+høyredrag eller Shift+midtdrag zoomer", "nav.perspective.sketch": "Skisseredigering bruker ortografisk visning. Fullfør skissen for å bytte til perspektiv.", + "nav.right_drag_orbits.help": "Av: et høyredrag lar kameraet stå i ro, og høyre museknapp åpner bare kontekstmenyen. Når du velger SolidWorks-museoppsettet, slås dette av.", + "nav.right_drag_orbits.label": "Høyredrag roterer", "nav.solidworks.help": "Midtdrag roterer, Ctrl+midtdrag panorerer, Shift+midtdrag zoomer, høyredrag roterer", "nav.tinkercad.help": "Høyredrag roterer, midtdrag eller Shift+høyredrag panorerer", "nav.touchpad.help": "Hold Shift og flytt pekeren for å panorere. Hold Alt/Option og flytt pekeren for å rotere visningen. Du trenger ikke holde inne en museknapp. Vertikal rulling zoomer.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Tilpass navigasjonslinjen…", "navbar.custom.design": "Design", "navbar.custom.duplicate": "Dette elementet er allerede på linjen.", + "navbar.custom.edge": "Kant", + "navbar.custom.edge_bottom": "Nederst", + "navbar.custom.edge_top": "Øverst", "navbar.custom.fade": "Tone ned", "navbar.custom.full": "Fjern et element for å legge til et nytt.", "navbar.custom.hidden_design": "Skjult i Design", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Tykkelse", "parts.dimension.width": "Bredde", "parts.family.bearing": "Dypsporkulelager (ytre omriss)", + "parts.family.button_head_cap_screw": "Linsehodeskrue med innvendig sekskant", "parts.family.dowel_pin": "Parallellstift (passtift)", "parts.family.hex_bolt": "Sekskantbolt / sekskantskrue", "parts.family.hex_nut": "Sekskantmutter", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Jevner ut taggete kanter.", "prefs.applies_on_restart": "Tas i bruk ved omstart", "prefs.assistant": "Assistent", + "prefs.auto_operation": "Velg operasjon automatisk", + "prefs.auto_operation_note": "Funksjonsdialoger velger Slå sammen, Ny kropp eller Klipp ut ut fra hva det nye materialet berører, helt til du velger selv. Av: de beholder operasjonen de åpnes med.", "prefs.axes": "Akser", "prefs.axis": "Akse", "prefs.bodies_folders": "Kroppsmapper", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Nederst i midten", "prefs.bottom_right": "Nederst til høyre", "prefs.browser": "Modelltre", + "prefs.browser_eye_left": "Øyet ved siden av foldepilen", + "prefs.browser_eye_left_note": "Radene i modelltreet viser vis/skjul-øyet ved siden av foldepilen, som før, i stedet for i en kolonne ytterst til høyre i radene.", + "prefs.browser_reveal_on_hover": "Vis når pekeren er over", + "prefs.browser_show_note": "Vist holder modelltreet på skjermen. Vis når pekeren er over holder det skjult til pekeren kommer over plassen det har, eller over den tynne fanen ved venstre kant, og det forblir åpent mens pekeren er inne i det eller en meny fra det er åpen.", + "prefs.browser_shown": "Vist", "prefs.built_in": "Innebygd", "prefs.button_left": "Dra med venstre museknapp", "prefs.button_middle": "Dra med midtre museknapp", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Trer i kraft etter at SolidCraft er startet på nytt. WGPU_ADAPTER_NAME overstyrer dette valget.", "prefs.graphics_note": "Velg mellom hastighet og jevnhet. Hvert valg viser effekten på et eksempel.", "prefs.graphics_presets_note": "Forhåndsinnstillinger angir valgene nedenfor samlet.", + "prefs.hide_constraint_glyphs_zoomed_out": "Skjul bindingssymboler når det er zoomet ut", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Bindingssymbolene forsvinner fra visningen når skissen er zoomet ut så langt at de ville rotet til den. Av: de beholder en fast størrelse på skjermen ved alle zoomnivåer.", "prefs.history": "Historikk", "prefs.history_origin_bodies": "Vis origo og kropper øverst", "prefs.history_origin_bodies_note": "Tidslinjen til venstre eller under modelltreet starter med Kropper-mappen til den aktive komponenten og Origo, med øyne og menyer som i modelltreet. Tidslinjen nederst er uendret.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Kompakt fester panelene til vinduskantene. Gjennomsiktig modelltre er tilgjengelig i Flytende. Endre siden for assistenten på assistentsiden.", "prefs.left": "Venstre", "prefs.left_click": "Venstreklikk", + "prefs.line_drag_draws_one": "Trykk og dra tegner én linje", + "prefs.line_drag_draws_one_note": "I Linje-verktøyet tegner trykk, dra og slipp én linje fra der du trykket til der du slapp, og linjen avsluttes der. Klikk tegner fortsatt en kjede. Av: et drag plasserer et punkt der det slippes, slik et klikk gjør.", "prefs.linked_designs": "Koblede design", "prefs.live_input": "Direkte inndata", "prefs.live_preview": "Direkte forhåndsvisning", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "STEP- og importfiler", "prefs.max_file_size_web": "Nettappen åpner STEP- og importfiler opptil 512 MB og design opptil 256 MB: en nettleserfane kan ikke romme større filer. Grensene kan økes i skrivebordsappen.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Store ikoner", + "prefs.menu_list": "Liste", + "prefs.menus": "Menyer", "prefs.minutes": "minutter", "prefs.minutes_suffix": " min", "prefs.mouse": "Mus og styreflate", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Plassering", "prefs.prefer_free_sketch_points": "Foretrekk flyttbare skissepunkter når punkter overlapper", "prefs.preview_choices": "Forhåndsvisning av valgene dine", + "prefs.print_face_highlight": "Utheving av utskriftsflate", + "prefs.print_face_highlight_tip": "Hvor sterkt et legemes utskriftsflate fargelegges og omrisses i visningen. 0 % viser ingen utheving.", + "prefs.profile_active": "Aktiv profil", + "prefs.profile_applied": "Profilen er brukt.", + "prefs.profile_changed": "Innstillingene er endret siden profilen ble lagret. Lagre beholder dem i profilen.", + "prefs.profile_deleted": "Profilen er slettet.", + "prefs.profile_export": "Eksporter…", + "prefs.profile_exported": "Profilen er eksportert.", + "prefs.profile_imported": "Profilen er importert. Velg den under Aktiv profil for å bytte til den.", + "prefs.profile_name_hint": "Profilnavn, f.eks. Laptop", + "prefs.profile_rename": "Gi nytt navn", + "prefs.profile_renamed": "Profilen har fått nytt navn.", + "prefs.profile_save": "Lagre", + "prefs.profile_saved": "Gjeldende innstillinger er lagret i profilen.", + "prefs.profile_skipped": "Profilen er brukt; {n} innstilling(er) beholdt sin nåværende verdi (se loggen).", + "prefs.profiles": "Innstillingsprofiler", + "prefs.profiles_note": "Snarveier, verktøylinjen, navigasjon, radialmenyen og grensesnittinnstillinger, samlet under ett navn og byttet på én gang.", + "prefs.project_options_dialog": "Dialog med alternativer for projisering", + "prefs.project_options_dialog_note": "På: velg Geometri eller Kropper og Projeksjonskobling før du projiserer. Av: behold den forrige arbeidsflyten med direkte valg.", "prefs.quality_default": "standard", "prefs.quality_fastest": "raskest", "prefs.quality_slowest": "langsomst", "prefs.quality_smoother": "jevnere", + "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", + "prefs.ribbon_reveal": "Vis søkeresultater på båndet", + "prefs.ribbon_reveal_note": "Når pekeren holdes over et resultat i Søk i kommandoer, vises hvor kommandoen ligger på båndet: fanen, knappen eller raden i et panels nedtrekksmeny. En kommando som startes fra søket, blinker der, og en kjørende kommando fra en nedtrekksmeny markerer panelet sitt.", "prefs.right_click": "Høyreklikk", "prefs.saving": "Automatisk lagring og gjenoppretting", "prefs.scale_note": "Også Ctrl + / Ctrl − i vinduet. Knapper er 28 px ved 100 %.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Ingen samsvarende innstillinger", "prefs.select": "Velg", "prefs.select_one_item": "Velg ett element", + "prefs.select_other_hidden": "Velg skjult geometri med Velg annet", + "prefs.select_other_hidden_help": "På: ta med skjulte kropper, skisser og tildekte kanter nær pekeren. Av: bruk den opprinnelige listen over synlig geometri under pekeren.", "prefs.selecting": "Valg", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift legger bare til; Ctrl / Cmd veksler", "prefs.shift_add_note": "På: Shift legger til, Ctrl / Cmd veksler. Av (standard): begge veksler.", "prefs.shortcuts": "Snarveier", + "prefs.single_surface_analysis": "Bruk én overflateanalyse om gangen", + "prefs.single_surface_analysis_help": "Når dette er av, kan overflateskyggelegging og Kontinuitet være på samtidig. Når dette slås på, beholdes den siste analysen som ble startet eller endret.", + "prefs.sketch_align_points": "Juster etter andre skissepunkter under tegning", + "prefs.sketch_align_points_note": "Under tegning justeres et punkt vannrett etter ett skissepunkt og loddrett etter et annet samtidig, med en prikket hjelpelinje, og klikket beholder begge justeringene. Av: bare etter forrige punkt, én akse om gangen.", "prefs.sketch_conflict_colour": "Motstridende geometri i konfliktfargen", "prefs.sketch_conflict_colour_note": "Mens bindingene i en skisse er i konflikt, tegnes også kurvene og punktene som en feilende binding holder i konfliktfargen, ikke bare symbolet eller målet. Av: de beholder sine vanlige farger.", + "prefs.sketch_construction_lines": "Utvidede hjelpelinjeverktøy", "prefs.sketch_exit_view": "Når en skisse avsluttes", "prefs.sketch_exit_view.keep": "Behold gjeldende visning", "prefs.sketch_exit_view.previous": "Gå tilbake til forrige visning", "prefs.sketch_exit_view.unless_rotated": "Gå tilbake med mindre visningen er rotert", "prefs.sketch_exit_view_note": "Hvor visningen går etter Fullfør skisse, Avbryt eller dobbelt Esc.", + "prefs.sketch_open_look_at": "Orienter mot skissen når den åpnes", + "prefs.sketch_open_look_at_note": "Av: å opprette eller redigere en skisse lar visningen stå som den er. Orienter mot vender visningen mot skissen når du vil.", + "prefs.sketch_recent_snap": "Fang automatisk til nylige sentre og midtpunkter", + "prefs.sketch_recent_snap_note": "Under tegning fanges markøren der hjelpelinjene fra de to siste sirkelsentrene eller midtpunktene på rette kanter krysser hverandre. Av: hjelpelinjene vises bare, og markøren flytter seg ikke.", "prefs.sketch_selection": "Skisse og valg", "prefs.sketch_selection_heading": "Skisse og valg", "prefs.sketch_selection_note": "Hvordan skisser bruker punktfangst, og hvordan klikk velger.", + "prefs.sketch_state_icons": "Skisseikoner i fargen til tilstanden deres", + "prefs.sketch_state_icons_note": "I tidslinjen og modelltreet får skisseikonet fargen til skissens tilstand (underbestemt, fullt bundet eller i konflikt), og verktøytipset oppgir tilstanden. Av: alle skisser har samme ikon.", "prefs.sketch_tab_always": "Behold Skisse-fanen synlig", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Kilde", "prefs.speed": "Hastighet", "prefs.stand_up_note": "Når en STEP- eller IGES-fil åpnes med Z opp, tilby en kvart omdreining rundt X.", + "prefs.stand_up_unknown": "For filer fra ukjente programmer", + "prefs.stand_up_unknown.ask": "Spør i en dialog", + "prefs.stand_up_unknown.note": "Vis en melding", + "prefs.stand_up_unknown.off": "Ikke tilby", + "prefs.stand_up_unknown_note": "Hvordan tilbudet gis når filhodet ikke nevner noe program som er kjent for å lagre med Y opp. En melding vises bare når filen ser ut til å ligge på ryggen. Spør i en dialog spør om hver slik fil, som før.", "prefs.status_bar": "Statuslinje", "prefs.status_bar_note": "En tynn linje langs vinduets nedre kant viser hva som redigeres, tilstanden til den åpne skissen i dens farge og designets enheter; et klikk på enhetene endrer dem. Skissens tilstand vises da ikke lenger i hjørnet av visningen.", "prefs.status_done": "Ferdig", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Varsler", "prefs.toggle_item": "Legg til elementet, eller fjern det igjen", "prefs.toolbar": "Verktøylinje", + "prefs.toolbar_tabs": "Verktøylinjefaner", "prefs.top": "Topp", + "prefs.trackpad_zoom_modifier": "Zoom med tofingerrulling mens du holder", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Hold bare denne tasten for å zoome mot pekeren med vertikal rulling. Av beholder den eksisterende rulleoppførselen.", + "prefs.trackpad_zoom_modifier.off": "Av", "prefs.translucent": "Gjennomsiktig", "prefs.under_browser": "Under modelltreet", "prefs.under_cursor": "Under pekeren", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Visningsområde", + "prefs.viewport_analysis_status": "Vis analysestatus i visningsområdet", + "prefs.viewport_analysis_status_help": "Vis aktive analyser over visningsområdet, åpne tegnforklaringene deres fra hvert element, og bruk Esc for å slå av den nyeste analysen. Slå av for å gjenopprette tegnforklaringer i hjørnene og den tidligere Esc-oppførselen.", + "prefs.viewport_background": "Bakgrunn", + "prefs.viewport_background_colour": "Farge", + "prefs.viewport_background_theme": "Temaets gradient", "prefs.wheel": "Musehjul", "prefs.wheel_trackpad": "Musehjul og styreflate", "prefs.workspace": "Arbeidsområde", + "prefs.zoom_stop_at_model": "Stopp zoom ved modellen", + "prefs.zoom_stop_at_model_tip": "3D-musen og enhetsappen zoomer mot flaten midt i visningen; dra-zoom holder fast punktet der draget begynte. Av: zoom kan gå gjennom modellen. Hjul og knip bruker Zoom til markøren separat", "prefs.zoom_toward_cursor": "Zoom mot pekeren", "properties.title": "Egenskaper: {name}", "recovery.days_ago": "for {count} dager siden", "recovery.detail": "{file} · {features} funksjoner · automatisk lagret {when}", + "recovery.discard_all": "Forkast alle", + "recovery.discard_all_detail": "Dette kan ikke angres.", + "recovery.discard_all_title": "Forkast {count} automatiske lagringer?", + "recovery.discard_all_title_one": "Forkast 1 automatisk lagring?", "recovery.hours_ago": "for {count} t siden", "recovery.just_now": "nettopp", "recovery.min_ago": "for {count} min siden", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Vekt", "render.appearance_physics_separate": "Visuelt utseende tildeler ikke tetthet, termiske data eller andre tekniske egenskaper.", "render.appearances": "Utseender", + "render.assign_help": "Klikk for å bruke på utvalget; dra for å forhåndsvise og slipp, eller klikk uten noe valgt for å male.", "render.availability_native": "Innebygd sammensetning; gjennomgå før bruk", "render.availability_projection": "Støttet projeksjon; se omfang og utelatelser", "render.availability_reference": "Referansedata; ikke en automatisk tildeling", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Terskel for lineære høylys", "render.browse_capabilities": "Inspiser hele katalogen over funksjoner", "render.browse_environments": "Bla gjennom originale HDR-omgivelser", + "render.browse_looks": "Bla gjennom…", "render.browse_native_scenes": "Bla gjennom innebygde studioer", "render.browse_physical": "Inspiser fysiske referanser", "render.browse_quality": "Inspiser kvalitetsspesifikasjoner", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Transparens: bare ugjennomsiktige flater; bakgrunnsalfa støttes.", "render.check_sorted_alpha": "Transparens: tilnærming med sortert alfa.", "render.check_transmission": "Transparens: fysisk transmisjon.", + "render.children.absolute_floor": "Absolutt nedre grense", + "render.children.adaptive": "Adaptiv", + "render.children.add_look_reason": "Legg til en stil for å redigere om den er aktivert, og styrken.", + "render.children.all_shots": "Alle opptak", + "render.children.bloom": "Bloom", + "render.children.check": "Sjekk", + "render.children.check_now": "Sjekk nå", + "render.children.chromatic": "Kromatisk", + "render.children.compact": "Kompakt", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopier", + "render.children.copy_job": "Kopier jobb-JSON", + "render.children.copy_request": "Kopier forespørsels-JSON", + "render.children.coverage_error": "Dekningsfeil", + "render.children.cpu": "CPU-strålesporing", + "render.children.denoise": "Reduser støy i det ferdige bildet", + "render.children.depth": "Banedybde", + "render.children.distortion": "Forvrengning", + "render.children.edge_samples": "Kantprøver", + "render.children.effects_banner": "Disse effektene krever GPU-banesporing. Å åpne dette endrer aldri metoden.", + "render.children.enable_adaptive": "Aktiver uavhengige pilotprøver for å redigere de adaptive grensene.", + "render.children.enable_bloom": "Aktiver bloom for å redigere styrke, terskel og radius.", + "render.children.finishing": "Etterbehandling", + "render.children.flare": "Linseflekk", + "render.children.fresnel": "Fresnel-sjikt", + "render.children.ghosts": "Spøkelsesringer", + "render.children.glass": "Glasstransport", + "render.children.go_to": "Gå til {editor}", + "render.children.grain": "Korn", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Behold rå lineær master", + "render.children.last_result": "Siste: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Linseeffekter", + "render.children.look_summary": "{tone} · {count} stiler", + "render.children.looks": "Kreative stiler", + "render.children.memory": "Minne", + "render.children.method": "Metode", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Krever CPU-strålesporing. Å åpne dette endrer aldri metoden.", + "render.children.nested": "Sertifiserte nestede dielektrika", + "render.children.not_checked": "Ikke sjekket", + "render.children.path": "Banesporing", + "render.children.pilots": "Pilotprøver", + "render.children.preview": "Forhåndsvisningsdata", + "render.children.progressive_reason": "Velg Progressiv for å redigere antall prøver og startverdi.", + "render.children.radius": "Radius", + "render.children.reflect_depth": "Refleksjonsdybde", + "render.children.reflect_samples": "Refleksjonsprøver", + "render.children.regrade": "Ny fargekorrigering", + "render.children.relative_error": "Relativ feil", + "render.children.render": "Rendring", + "render.children.render_shot": "Rendre opptak", + "render.children.renderer": "Rendringsmotor", + "render.children.renderers": "Rendringsmotorer", + "render.children.request": "Denne forespørselen", + "render.children.reset_finishing": "Tilbakestill etterbehandling", + "render.children.review_quality": "Gjennomgå en kvalitetsspesifikasjon…", + "render.children.rgb_gain": "RGB-forsterkning", + "render.children.samples": "Prøver", + "render.children.sampling": "Sampling", + "render.children.saturation": "Metning", + "render.children.scene_summary": "{bodies} kropper · {triangles} trekanter", + "render.children.seed": "Startverdi", + "render.children.sharpening": "Skjerping", + "render.children.single_image": "Enkeltbilde", + "render.children.soft_focus": "Mykt fokus", + "render.children.streak": "Strek", + "render.children.strength": "Styrke", + "render.children.studio": "Studio", + "render.children.threshold": "Terskel", + "render.children.tone": "Tone", + "render.children.under_hood": "Under panseret", + "render.children.vignette": "Vignettering", + "render.children.workstation": "Arbeidsstasjon", + "render.choose_look": "Velg en stil i Utseender først", "render.chromatic_offset": "Kromatisk kantforskyvning", "render.clear_stage": "Fjern lagret rendringsdekor", "render.copy_request": "Kopier JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Styrke på linseflekk i høylys", "render.flare_threshold": "Terskel for linseflekk (scenelineær)", "render.fold_browser": "Brett sammen modelltreet", + "render.frame_aspect": "Sideforhold", + "render.frame_centre_mark": "Midtmerke", + "render.frame_copy_limit": "Ingen ledige navn for duplisert opptak", + "render.frame_copy_name": "{name} kopi {number}", + "render.frame_custom": "Egendefinert", + "render.frame_distance_unit": " × størrelse", + "render.frame_duplicate": "Dupliser", + "render.frame_exposure": "Eksponering", + "render.frame_fov": "Synsfelt", + "render.frame_guides": "Hjelpelinjer", + "render.frame_invalid_aspect": "Ugyldig forhåndsinnstilling for sideforhold", + "render.frame_lens": "Synsfelt {fov}° · ≈ {mm} mm", + "render.frame_name": "Navn", + "render.frame_output_size": "Utdatastørrelse", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Portrett", "render.frame_presets": "Forhåndsinnstillinger for bilderute", "render.frame_presets_pending": "Kommer med opptakseditoren", + "render.frame_projection": "Projeksjon", + "render.frame_render_shot": "Rendre dette opptaket", + "render.frame_rule_thirds": "Tredjedelsregelen", + "render.frame_safe_area": "Sikkert område", + "render.frame_shot_title": "Opptak · {name}", + "render.frame_square": "1:1 · Kvadrat", + "render.frame_tall": "9:16 · Vertikal", + "render.frame_thirds": "Tredjedeler", + "render.frame_wide": "16:9 · Bredformat", "render.gpu_depth": "Maksimal banedybde", "render.gpu_options_help": "Prøver, dybde, startverdi og eksponering lagres med dette oppsettet. Bare den aktive bildemetoden lagres.", "render.gpu_path_failed": "GPU-banesporing er ikke tilgjengelig", @@ -2360,6 +2627,7 @@ "render.heading_results": "RESULTATER", "render.heading_shots": "OPPTAK", "render.hide_shots_label": "Skjul opptaksstripen", + "render.hover_look": "{object} · {look} · klikk for å velge", "render.image_advanced": "Avanserte GPU-innstillinger", "render.image_cancelled": "Rendring avbrutt", "render.image_cancelling": "Avbryter", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Bruk den ensartede tilnærmingen uten romlige eller avanserte effekter", "render.library_usable": "Kjørbare projeksjoner", "render.library_users": "Brukes av", + "render.light_aim": "Sikt mot", "render.light_anchor_pending": "Det er ikke støttet ennå å endre lysets koordinatsystem.", + "render.light_azimuth": "Asimut", + "render.light_backend_limit": "Velg CPU-strålesporing eller et eksplisitt GPU-studio for å redigere lys. Eldre GPU-studiolys forblir skrivebeskyttet.", + "render.light_behaviour": "OPPFØRSEL", + "render.light_default_fill": "Fyllys", + "render.light_default_key": "Hovedlys", + "render.light_default_number": "Lys {number}", + "render.light_default_rim": "Kantlys", + "render.light_delete": "Slett", + "render.light_distance": "Avstand", + "render.light_duplicate": "Dupliser", + "render.light_elevation": "Elevasjon", + "render.light_gpu_punctual": "Punktlys og spotlys er tilgjengelige med CPU-strålesporing.", + "render.light_hard_shadow": "Dette lyset har harde skygger; mykhet for lyskilden er ikke tilgjengelig.", + "render.light_intensity": "Intensitet", + "render.light_missing": "Dette lyset finnes ikke lenger. Velg et lys i modelltreet.", + "render.light_place_highlight": "Plasser etter høylys", + "render.light_place_pending": "Plassering etter høylys krever valg av lys i visningsområdet og støttes ikke ennå.", + "render.light_position": "POSISJON", + "render.light_preview_help": "Live-visningen er uten skygger; arealys bruker en tilnærming med én prøve i midten. Rendring bruker den fullstendige lysmodellen.", + "render.light_product_centre": "Produktets senter", + "render.light_size": "Størrelse", + "render.light_softness": "Mykhet", + "render.light_temperature": "Fargetemperatur", + "render.light_temperature_help": "Omtrentlig svartlegemefarge; lagres som lineær RGB.", + "render.light_type": "Type", + "render.light_type_area": "Areal", + "render.light_type_point": "Punkt", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Sol", + "render.light_view_only": "Solo og demping påvirker denne visningen og rendringene fra den; de lagres aldri i designet.", + "render.light_world_origin": "Verdens origo", + "render.lighting_aim_point": "Siktepunkt", + "render.lighting_apply": "Bruk forhåndsinnstilling", + "render.lighting_area_samples": "Arealprøver", + "render.lighting_budget": "Budsjett", + "render.lighting_budget_help": "Antallene er skrivebeskyttet. Legg til, dupliser eller slett lys for å endre dem; metoden bestemmer grensene.", + "render.lighting_colour": "Farge", + "render.lighting_consent": "Erstatt retningslysene med denne forhåndsinnstillingen", + "render.lighting_custom": "Egendefinerte lys", + "render.lighting_custom_help": "Når du velger Egendefinert, gjøres forhåndsinnstillingen om til redigerbare lys, med én rad for hvert i treet.", + "render.lighting_direction": "Retning X Y Z", + "render.lighting_direction_help": "Eller skriv inn en retning mot lyset (X Y Z).", + "render.lighting_distance_unit": " × størrelse", + "render.lighting_edge_u": "Sidevektor U", + "render.lighting_edge_v": "Sidevektor V", + "render.lighting_falloff": "Avtaking", + "render.lighting_falloff_help": "Intensiteten er oppgitt ved referanseavstanden.", + "render.lighting_height": "Høyde V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Demp dette lyset", + "render.lighting_point": "Punkt", + "render.lighting_preset": "Studioforhåndsinnstilling", + "render.lighting_preset_cpu": "Forhåndsinnstillinger for studio med retningslys krever CPU-strålesporing. GPU-belysning bruker eksplisitte sol- og arealys.", + "render.lighting_preset_row": "Forhåndsinnstilling", + "render.lighting_radiance": "Stråling", + "render.lighting_reference": "Referanse", + "render.lighting_review_help": "Erstatter listen over retningslys. Beholder punktlys og arealys, omgivelse, skygger, kamera og alle andre opptaksinnstillinger.", + "render.lighting_review_title": "Gjennomgå forhåndsinnstilling for belysning", + "render.lighting_samples_cpu": "Prøver for arealys gjelder for CPU-strålesporing.", + "render.lighting_samples_need_area": "Legg til et CPU-arealys for å angi arealprøver.", + "render.lighting_samples_reason": "Arealprøver må være 1, 4, 16 eller 64.", + "render.lighting_shadow_cpu": "Skyggebryteren krever CPU-strålesporing. GPU-banesporing kaster alltid skygger.", + "render.lighting_shadows": "Kast skygger", + "render.lighting_shadows_section": "Skygger og sampling", + "render.lighting_shape": "Form", + "render.lighting_solo": "Solo for dette lyset", + "render.lighting_source": "Kilde", + "render.lighting_source_section": "Lyskilde", + "render.lighting_spot_preserved": "Dette punktlyset beholder den lagrede spotlyskjeglen til typen endres.", + "render.lighting_sun_distance": "Solretningen er uavhengig av avstand og siktepunkt.", + "render.lighting_unsupported": "støttes ikke", + "render.lighting_used": "{used} av {limit}", + "render.lighting_width": "Bredde U", + "render.lighting_world_position": "Verden X Y Z", "render.lights_fixed_to": "Lys festet til", "render.linear_help": "Beholder en RGBA-master med 32-biters flyttall før støyreduksjon, eksponering, tonekartlegging, filtre og bakgrunn. Lineær sRGB/D65; dekningsforhåndsmultiplisert. Ekstra minne tillates eksplisitt. Ingen komprimering eller ekstra pass.", "render.linear_retain": "Behold rå lineær EXR", "render.linear_unavailable": "Det valgte ferdige bildet har ingen beholdt EXR. Aktiver beholdning før rendring; en eldre PNG kan ikke gjenskape rå radians.", "render.look_add": "Legg til fargestil", + "render.look_applied": "Brukt: {name}", "render.look_bleach": "Inspirert av bleach bypass", "render.look_cool": "Kjølig", "render.look_cyanotype": "Inspirert av cyanotypi", "render.look_faded": "Falmet trykk", + "render.look_material": "Materiale", "render.look_mono": "Monokrom", + "render.look_pasted": "Stil limt inn", "render.look_sepia": "Sepia", "render.look_strength": "Stilstyrke", "render.look_teal_orange": "Blågrønn / oransje", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Avansert", "render.output_backend_ready": "Klar", "render.output_check": "Sjekk", + "render.output_check_failed": "Sjekk · feil", "render.output_check_help": "Kjør Sjekk for å gå gjennom denne forespørselen uten å starte en rendring.", "render.output_check_summary": "Sjekk · Merknader: {count}", "render.output_copy": "Kopier", + "render.output_copy_report": "Kopier rapport", "render.output_destination": "Destinasjon", "render.output_destination_help": "Velg destinasjonsfilen når du eksporterer det ferdige bildet.", "render.output_dimensions": "Størrelse", @@ -2604,6 +2952,7 @@ "render.output_fit": "Tilpass", "render.output_format": "Format", "render.output_format_status": "PNG er det nøyaktige ferdige visningsbildet. Valgfri GPU-EXR beholder rå scenelineær FLOAT32-radians før visningsfiltre. Eksport erstatter aldri forhåndsvisningsbufferen eller en annen jobb.", + "render.output_grading": "Fargekorrigering", "render.output_image": "Bilde", "render.output_largest": "maks. {width} × {height}", "render.output_lock_ratio": "Lås sideforhold", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "venter på kvalitetskontrakten", "render.output_ratio_refused": "Det koblede målet overskrider størrelsesgrensen for denne metoden. Forrige størrelse beholdes; lås opp forholdet eller velg en mindre verdi.", "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_ok": "Størrelse OK · {width} × {height} px", "render.output_swap_orientation": "Bytt bredde og høyde", "render.output_tools_inspector": "Rediger størrelse og format i utdatainspektøren.", @@ -2629,6 +2980,7 @@ "render.page_output": "Utdata", "render.page_scene": "Scene", "render.paint_label": "Maling", + "render.painting_with": "Maler med {name}", "render.passive_stage": "Gulv og passivt rendringsdekor", "render.passive_stage_help": "Endelige passive paneler hører bare til rendrede bilder. De legger ikke til CAD-kropper eller masse. Plasseringen er fast etter at den er brukt; skjæring med produktets avgrensning avvises. CPU gir ikke diffus indirekte belysning.", "render.perspective": "Perspektivkamera", @@ -2650,6 +3002,7 @@ "render.pick_help": "Velg kropp, flate eller komponent. Klikk i tomt område for å fjerne valget; Shift eller Ctrl legger til eller veksler. Utheving vises bare i live-produktvisningen.", "render.pick_label": "Velg", "render.pick_up_label": "Plukk opp", + "render.pick_up_prompt": "Klikk på et objekt for å plukke opp stilen", "render.pick_waiting": "Valg i produktvisningen støttes ikke ennå.", "render.post_contrast": "Kontrast", "render.post_gain_b": "Blå forsterkning", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Radiell forvrengning", "render.radial_fit": "Tilpass produkt", "render.radial_save": "Lagre opptak", + "render.recent_looks": "Nylige stiler", "render.regrade_action": "Fargekorriger råmaster på nytt (uten støyfjerning)", "render.regrade_help": "Oppretter en separat PNG fra den valgte ferdige råmasteren med disse presentasjonskontrollene. Sporer ingen stråler, endrer ikke registrert størrelse, kamera eller lys, og overskriver ikke EXR-filen. Støyfjerning brukes ikke på nytt: råmastere beholder ikke hjelpedataene for den. Den avledede filen kan fortsatt eksporteres med de samme jobbkontrollene.", "render.regrade_label": "Ny fargekorrigering", "render.reload_settings": "Last inn lagrede innstillinger på nytt", + "render.remove_override": "Fjern overstyring", + "render.replace_all": "Erstatt alle", "render.replace_ground": "Bekreft erstatning av eksisterende rendringsdekor", "render.replace_label": "Erstatt", "render.replace_saved_surface": "Erstatt en eksisterende lagret tilordning for dette målet", + "render.replace_with": "Erstatt med", "render.restore_camera": "Gjenopprett lagret kamera", "render.result_actual": "100%", "render.result_actual_help": "Én bildepiksel per skjermpiksel.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Fjern lagret tilordning", "render.saved_surfaces": "Lagrede overflatetilordninger", "render.saved_surfaces_help": "Festede innebygde kart følger den valgte forekomsten i lokale millimeter. Et erstattet eller manglende mål er en feil, aldri en automatisk ny tilordning.", + "render.scene_all_shots": "Alle opptak ({count})", "render.scene_apply": "Bruk gjennomgått studio", + "render.scene_batch_reason": "Alle opptak krever rendringskøen for lagrede opptak, som ikke er tilgjengelig ennå.", + "render.scene_check_now": "Sjekk nå", + "render.scene_check_passed": "Sjekk bestått · {notes} merknader", + "render.scene_check_stale": "Sjekken er utdatert · sjekk på nytt", + "render.scene_check_unchecked": "Ikke sjekket ennå · 0 merknader", "render.scene_consent": "Bekreft erstatning av belysning, omgivelse, eksponering og rendringsdekor", + "render.scene_light_summary": "{environment} · {lights} lys", + "render.scene_look_summary": "{looks} stiler · {overrides} overstyringer", + "render.scene_open_check": "Åpne Sjekk", "render.scene_preserved_fields": "Kamera, sampling, bildestørrelse, optiske tilordninger, CAD-geometri og tekniske materialer beholdes.", "render.scene_preserves_design": "Komponer belysning og et passivt rendringsdekor for det synlige motivet. Geometri, tekniske materialer og kamera forblir adskilt.", + "render.scene_ready": "Klar til rendring", + "render.scene_reminder": "Scene bevarer designet. Geometri, tekniske egenskaper og CAD endres aldri fra Rendring.", + "render.scene_reminders": "Påminnelser", + "render.scene_renderer_ready": "{renderer} er klar", "render.scene_replaces": "Erstatter", "render.scene_review": "Avklar og gjennomgå", "render.scene_review_help": "Gå gjennom hele den innebygde komposisjonen før du bruker den. Ubundne avanserte SCENE-spesifikasjoner erstattes ikke.", "render.scene_scope_details": "Komposisjonens omfang", + "render.scene_stages": "Trinn", "render.search_appearances": "Søk i {count} utseender", "render.setup_legacy_environment": "Dette eldre oppsettet bruker utdatert rotasjon eller intensitet for omgivelsen. Disse verdiene beholdes; konverter dem eksplisitt før du lagrer en full bildejobb.", "render.setup_name": "Oppsettsnavn", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "RENDRINGSKAMERA", "render.shell_tab_lighting": "BELYSNING", "render.shell_tab_scene": "RENDRINGSSCENE", + "render.shot_aperture": "Blender", + "render.shot_distance": "Avstand", + "render.shot_dof": "Dybdeskarphet", "render.shot_duplicate": "Dupliser lagret opptak", + "render.shot_edit_output": "Rediger i Utdata", + "render.shot_exposure_reason": "Eksponering krever CPU-strålesporing eller GPU-banesporing.", + "render.shot_exposure_section": "Eksponering", + "render.shot_focus_distance": "Fokusavstand", + "render.shot_fov_reason": "Synsfelt krever perspektivprojeksjon.", + "render.shot_frame_subject": "Ramme inn motivet", + "render.shot_invalid_camera": "Kameraverdiene må være endelige og innenfor gyldig område.", + "render.shot_lens": "Linse", + "render.shot_lens_enable_reason": "Aktiver dybdeskarphet for å redigere blender og fokusavstand.", + "render.shot_lens_gpu_reason": "Dybdeskarphet krever GPU-banesporing.", + "render.shot_lens_perspective_reason": "Dybdeskarphet krever perspektivprojeksjon.", + "render.shot_look_selection": "Sikt mot utvalget", "render.shot_manager": "Lagrede opptak", "render.shot_new_name": "Navn på nytt opptak", "render.shot_remove": "Fjern lagret opptak", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Fjerne lagret opptak?", "render.shot_rename": "Gi nytt navn til lagret opptak", "render.shot_saved_only": "Lagre det gjeldende utkastet først. Duplisering og omdøping beholder de nøyaktige lagrede alternativene og ressursreferansene.", + "render.shot_selection_reason": "Velg en kropp eller flate å sikte mot.", + "render.shot_size": "Størrelse", + "render.shot_target": "Mål", "render.shots_camera_changed": "Kameraet er endret siden dette opptaket ble lagret", "render.shots_name_help": "Skriv inn et unikt navn på det gjeldende opptaket.", "render.shots_no_result": "Ingen rendring ennå", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "støttes ikke ennå: valg av produkt og opptak av utseende", "render.wait_replace": "støttes ikke ennå: erstatning av alle bruk av et utseende", "render.wait_solo": "støttes ikke ennå: lysenes synlighet og solo-kontroller", + "render.wear_applies_to": "Gjelder for", + "render.wear_layer_count": "{count} lag", + "render.wear_layer_one": "{count} lag", + "render.wear_layers": "Lag", + "render.wear_no_layers": "Ingen lag", + "render.wear_selected_layer": "Valgt lag", + "render.wear_title": "Slitasje", "sample.bracket.about": "Basisflens med kantflenser og kantomslag, med en plateregel for 2 mm tykkelse", "sample.bracket.name": "Platebrakett", "sample.enclosure.about": "Uthulet boks med leppekant, skruefester, anlegg og snap-fit", @@ -3030,6 +3426,7 @@ "select an axis": "velg en akse", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "velg en akse, en rett eller sirkulær kant, eller en sylinder-, kjegle- eller torusflate først", "select bodies first": "velg kropper først", + "select components first": "velg komponenter først", "select corner points, or the two lines of one corner": "velg hjørnepunkter, eller de to linjene i ett hjørne", "select one corner point, or the two lines of one corner": "velg ett hjørnepunkt, eller de to linjene i ett hjørne", "select sheet edges first": "velg platekanter først", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Dobbeltklikk på en komponent for å aktivere den, eller for å redigere en koblet komponent på stedet", "settings.frame_rate_details_note": "Legger til en graf over tidene for de siste bildene med trekantene som er tegnet oppå, et histogram over bildetider og hva GPU-en tegnet. Klikk på bildefrekvensen i visningen for å vise eller skjule dem.", "settings.frame_rate_note": "Viser bilder per sekund nederst til høyre i visningen.", + "settings.insert_lists_open_designs": "Sett inn design viser først designene som er åpne i andre faner", "settings.measure_axis_distances": "Vis X/Y/Z-avstander", "settings.measure_axis_distances_help": "Vis avstanden langs hver av de globale X-, Y- og Z-aksene sammen med totalavstanden mens Mål er åpen.", "settings.msaa_applies_on_restart": "Kantutjevning brukes neste gang SolidCraft starter", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Løsne", "sketch is missing": "skissen mangler", "sketch.degrees_of_freedom": "{count} frihetsgrader", + "sketch.dimension_added_driven": "Skissen bestemmer allerede dette målet, så det ble lagt til som et drevet mål (referansemål)", + "sketch.dimension_applied": "Målet er satt", + "sketch.dimension_applied_defined": "Målet er satt; parameteren `{name}` er definert", + "sketch.dimension_applied_renamed": "Målet er satt; parameteren har fått navnet `{name}`", "sketch.dof_left": "{count} frihetsgrader igjen", "sketch.dof_left_one": "{count} frihetsgrad igjen", "sketch.draw_curves": "Tegn som {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Bruk skriftfilen {path}", "solid": "volumkropp", "status.editing": "Redigerer {name}", + "status.navigation": "Museoppsett: {mapping}", + "status.navigation_tip": "Hvordan musen beveger visningen. Klikk for å velge et annet oppsett", "status.under_constrained": "Underbestemt ({count} frihetsgrader)", "status.under_constrained_one": "Underbestemt ({count} frihetsgrad)", "status.units_tip": "Designets enheter. Klikk for å endre dem", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Behold funksjonene og de nestede mappene i tidslinjen", "timeline.delete_folders_with_features_tip": "Slett mappene med alle funksjonene i dem og de nestede mappene deres", "timeline.delete_many": "Slett {count} valgte funksjoner", + "timeline.edited_rolled_back": "{name} er endret, men ligger etter tidslinjemarkøren og bygges derfor ikke: flytt markøren forbi den for å se resultatet", "timeline.feature_error": "Feil: {message}", "timeline.feature_hint": "Dobbeltklikk for å redigere · dra for å endre rekkefølge · høyreklikk for mer", + "timeline.feature_rolled_back": "Rullet tilbake: etter tidslinjemarkøren, så den bygges ikke", "timeline.feature_warning": "Advarsel: {message}", "timeline.group": "Ny funksjonsgruppe", "timeline.move_many": "Flytt tidslinjeelementer", "timeline.move_to_group": "Flytt funksjoner til gruppe", "timeline.rename_group": "Gi nytt navn til funksjonsgruppe", "timeline.reorder_many": "Omorganiser funksjoner", + "timeline.suppress_features": "Undertrykk funksjoner", "timeline.suppress_folders_tip": "Undertrykk eller opphev undertrykking av alle funksjoner i mappene og de nestede mappene deres", "timeline.ungroup": "Opphev funksjonsgruppering", + "timeline.unsuppress_features": "Opphev undertrykking av funksjoner", "to the face": "til flaten", "to the next face": "til neste flate", "toolbar.customize_note": "Fanen {tab}. Kryss av for en kommando for å legge til en knapp på verktøylinjen. Du kan også dra en rad fra panelets nedtrekksmeny til verktøylinjen, dra en knapp ut av verktøylinjen for å fjerne den, eller feste en kommando fra nedtrekksmenyen.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Kalibrering av 3D-musen retter opp byttede eller reverserte akser og stiller inn hastighet og følelse.", "ui.3d_mouse_offer_never": "Ikke vis igjen", "ui.3d_mouse_offer_swap": "Bytt til Hidraw (direkte driver)", + "ui.3d_mouse_orbit": "Rotasjon", + "ui.3d_mouse_orbit_classic": "Klassisk", + "ui.3d_mouse_orbit_tip": "Sporet: 3D-musen dreier visningen rundt den delen av modellen som er midt i visningen når dreiningen begynner, markert med en svak kule. Klassisk: rundt visningens sentrum", + "ui.3d_mouse_orbit_traced": "Sporet", "ui.3d_mouse_reading": "Avlesning", "ui.3d_mouse_reset_axes": "Tilbakestill aksetilordning", "ui.3d_mouse_reset_view": "Tilbakestill visning", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Vri knotten mot klokken, hold den, og slipp.", "ui.3d_mouse_switch_cap": "Kalibrer som Tilbake til midten", "ui.3d_mouse_switch_trackball": "Kalibrer som trackball", + "ui.3d_mouse_use_direct": "Bruk direkte driver", + "ui.3d_mouse_use_direct_tip": "Les 3D-musen direkte (hidraw) i stedet for gjennom spacenavd.", "ui.3d_mouse_view_axis": "Visningsakse", "ui.3d_mouse_view_off": "Av", "ui.3d_mouse_view_off_tip": "Profilen til 3D-musdriveren slår av denne aksen, så bevegelsen flytter ikke visningen. Hvis du velger en annen akses bevegelse her, bytter de to plass, og den bevegelsen slutter å flytte visningen.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Avbryt", "ui.canvases": "Lerreter", "ui.center": "Senter", + "ui.center_arc_follows_cursor": "Senterpunktbue følger markørens retning", + "ui.center_arc_follows_cursor_tip": "En senterpunktbue går med eller mot klokken, etter hvordan markøren beveger seg rundt senteret. Av: alltid mot klokken fra start.", "ui.centerline": "Senterlinje", "ui.chain": "Kjede", "ui.chamfer": "Fas", @@ -3570,6 +3986,7 @@ "ui.circle": "Sirkel", "ui.circular_pattern": "Sirkulært mønster", "ui.clear": "Tøm", + "ui.clear_print_face": "Fjern utskriftsflate", "ui.clear_recent": "Tøm listen over nylige design", "ui.clear_the_selection": "Tøm utvalget", "ui.click_selects_whole_bodies": "Klikk velger hele kropper", @@ -3604,6 +4021,15 @@ "ui.constant": "Konstant", "ui.constraints_conflict": "Bindinger i konflikt", "ui.construction": "Konstruksjon", + "ui.construction.angle_hint": "Hjelpelinje: klikk på et punkt, og skriv deretter inn en vinkel eller velg en retning", + "ui.construction.bisector_hint": "Vinkelhalveringslinje: velg to rette linjer som krysser hverandre, og deretter ønsket løsning", + "ui.construction.common_hint": "Felles tangent: velg to sirkler/buer, og deretter ønsket løsning", + "ui.construction.contact_hint": "Tangent: velg en sirkel/bue eller åpen spline/kjeglesnittkurve, og deretter berøringspunktet (frie kurver: endepunkter)", + "ui.construction.external_hint": "Tangent: velg en sirkel eller bue, et eksternt punkt, og deretter ønsket løsning", + "ui.construction.free_hint": "Hjelpelinje: klikk på to punkter; høyreklikk for å starte på nytt", + "ui.construction.point_hint": "Hjelpelinje: klikk på punktet linjen skal gå gjennom", + "ui.construction.reference_hint": "Hjelpelinje: velg en rett linje, og deretter et punkt; høyreklikk for å bytte referanse", + "ui.construction.ribbon_label": "Hjelpelinjer", "ui.context_menu": "Kontekstmeny", "ui.continuity_g0": "G0-knekk", "ui.continuity_g1": "G1-tangent", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Sluttskala", "ui.entities": "Objekter", "ui.equal": "Lik", + "ui.equation_end": "Slutt", + "ui.equation_explicit": "Eksplisitt", + "ui.equation_form": "Form", + "ui.equation_note": "Variabelen er et rent tall: sin(t) tolker det i radianer. Et resultat uten enhet er i designets enhet.", + "ui.equation_parametric": "Parametrisk", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalent radius", "ui.expand": "Utvid", "ui.exportDxf": "Eksporter DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "DESIGN", "ui.heading_draft": "SLIPPVINKEL", "ui.heading_edit": "REDIGER", + "ui.heading_equation_curve": "LIGNINGSKURVE", "ui.heading_extrude": "EKSTRUDERING", "ui.heading_fillet": "AVRUNDING", "ui.heading_finish_editing": "FULLFØR REDIGERING", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Importer…", "ui.infinite": "Uendelig", "ui.insert": "Sett inn", + "ui.insert_design.browse": "Bla gjennom…", + "ui.insert_design.open_designs": "Design som er åpne", + "ui.insert_design.saved_file": "{name} har ulagrede endringer, så den lagrede filen ble satt inn. Lagre den, og bruk deretter Oppdater koblinger for å hente inn endringene.", + "ui.insert_design.unsaved": "Ulagrede endringer", "ui.inspect": "Inspiser", "ui.intersect": "Snitt", "ui.isolate": "Isoler", @@ -3994,12 +4433,29 @@ "ui.polygon": "Mangekant", "ui.preferences": "Innstillinger", "ui.press_a_key": "trykk på en tast…", + "ui.print_face_cleared": "{body}: utskriftsflate fjernet", + "ui.print_face_input": "Utskriftsflate", + "ui.print_face_one_body": "En utskriftsflate hører til ett legeme: send det legemet alene for å endre den", + "ui.print_face_pick": "Velg en plan flate på et massivt legeme for å gjøre den til utskriftsflaten", + "ui.print_face_set": "{body}: utskriftsflate satt; Send til slicer legger legemet på den", "ui.profile": "Profil", "ui.profile_plane": "Profilplan", "ui.profiles": "Profiler", "ui.project": "Projiser", "ui.project_face_edges_sketch": "Projiser flatekanter inn i nye skisser", "ui.properties": "Egenskaper", + "ui.properties.copy_matrix": "Kopier matrise", + "ui.properties.first_moments": "Førsteordens momenter", + "ui.properties.inertia": "Treghetsmoment", + "ui.properties.inertia_note": "Om massesenteret, i verdensaksene (g·{unit}²)", + "ui.properties.inertia_unavailable": "Ikke tilgjengelig: nettet til en kropp er ikke lukket.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Måler…", + "ui.properties.measuring_failed": "Målingen stoppet før den var ferdig.", + "ui.properties.principal_axis": "Hovedakse {number}", + "ui.properties.principal_moments": "Hovedtreghetsmomenter", "ui.pull_direction": "Trekkretning", "ui.quality": "Kvalitet", "ui.quantity": "Antall", @@ -4045,6 +4501,7 @@ "ui.revolve": "Omdreining", "ui.ribbon_exit": "Avslutt", "ui.ribbon_labels": "Vis tekst under båndikonene", + "ui.ribbon_menus_skip_pinned": "Utelat festede kommandoer fra nedtrekksmenyene", "ui.ribbon_pin_footer": "Festede kommandoer vises på båndet. Hold pekeren over en rad for å feste eller løsne kommandoen.", "ui.ribbon_workspace": "Arbeidsområde", "ui.right": "Høyre", @@ -4062,6 +4519,7 @@ "ui.select": "Velg", "ui.selectOther": "Velg annet…", "ui.selectTangentChain": "Velg tangentkjede", + "ui.select_all": "Velg alle", "ui.selection": "Utvalg", "ui.selection_modifiers": "Utvalgsmodifikatorer", "ui.selection_shift_add_only": "Shift+klikk legger til uten å fjerne", @@ -4098,7 +4556,11 @@ "ui.sketch": "Skisse", "ui.sketch_dimension": "Skissemål", "ui.sketch_grid": "Skisserutenett", + "ui.sketch_intersect_geometry": "Geometri", + "ui.sketch_intersect_projection_link": "Projeksjonskobling", + "ui.sketch_intersect_selection_filter": "Utvalgsfilter", "ui.sketch_modifiers": "Skissemodifikatorer", + "ui.sketch_project_geometry": "Geometri", "ui.sketch_scale": "Skisseskala", "ui.sketches": "Skisser", "ui.slice": "Snittvisning", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Administrer", "ui.tab_sheet_metal": "Plate", "ui.tab_view": "Visning", + "ui.tab_weldments": "Sveisekonstruksjoner", "ui.tangent": "Tangent", "ui.taper_angle": "Konusvinkel", "ui.tapped": "Gjenget", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Spissvinkel", "ui.to_object": "Til objekt", "ui.tool_direction": "Verktøyretning", + "ui.toolbar_tabs_hidden": "Alle verktøylinjefaner er skjult. Klikk på + eller høyreklikk her for å vise en.", "ui.top": "Topp", "ui.top_diameter": "Toppdiameter", "ui.top_radius": "Toppradius", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Visningssenter", "ui.viewport_status_clear": "Slå av {name}", "ui.viewport_status_clear_hint": "Slå av {name} · Esc", + "ui.viewport_status_edges": "{count} kanter", + "ui.viewport_status_from": "fra {direction}", "ui.viewport_status_key": "Vis tegnforklaring for {name}", "ui.viewport_status_minimum_radius": "Minimumsradius", "ui.viewport_status_pairs": "{count} par", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Skyggelagt med kanter", "ui.visual_style": "Visuell stil", "ui.visual_wireframe": "Trådmodell", + "ui.wheel_zoom_speed": "Zoomhastighet for musehjulet", "ui.width": "Bredde", "ui.window_system": "Vindussystem", "ui.window_system_auto": "Automatisk", @@ -4240,7 +4707,9 @@ "update.button": "Oppdater", "update.button_version": "Oppdater til {version}", "update.check_automatically": "Se etter oppdateringer automatisk", + "update.check_for_updates": "Se etter oppdateringer", "update.check_now": "Sjekk nå", + "update.check_unavailable": "Søk etter oppdateringer er ikke tilgjengelig for denne installasjonen.", "update.checking": "Sjekker…", "update.copy": "Kopier", "update.dmg_opened": "Diskbildet er åpent og verifisert: dra SolidCraft til Applications, erstatt den gamle kopien, og start deretter SolidCraft på nytt.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} er installert. Start på nytt for å bruke den.", "update.installer_started": "Installasjonsprogrammet starter når SolidCraft lukkes.", "update.later": "Minn meg på senere", + "update.managed": "Oppdatering tilgjengelig: oppdater SolidCraft i {manager}", "update.no_notes": "Denne utgivelsen har ingen merknader.", "update.package_hint": "Denne kopien kom fra en {kind}. Last ned den nye pakken, og installer den med:", "update.prefs_dev": "Dette er et utviklingsbygg ({version}): det ser ikke etter oppdateringer.", "update.prefs_flatpak": "Flatpak oppdaterer denne kopien ({version}): kjør {command} eller bruk programvaresenteret. SolidCraft sjekker ikke.", "update.prefs_label": "Oppdateringer", + "update.prefs_managed": "Dette er SolidCraft {version}, administrert av {manager}. Ved en oppdateringssjekk spør programmet GitHub om den nyeste utgivelsen, og ingen andre opplysninger sendes. Oppdater SolidCraft i {manager}.", "update.prefs_note": "Dette er SolidCraft {version} ({kind}). Ved en oppdateringssjekk spør programmet GitHub om den nyeste utgivelsen. Ingen andre opplysninger sendes.", "update.prefs_web": "Last inn siden på nytt for å få den nyeste nettappen. Hvis nettverket ikke er tilgjengelig, eller oppdateringen ikke rekker å lastes ned i tide, åpnes den lagrede versjonen i stedet; en nedlasting som pågår, fortsetter til neste gang siden lastes inn på nytt.", + "update.refresh_checking": "Ser etter den nyeste utgivelsen…", + "update.refresh_fallback": "Kunne ikke se etter en nyere utgivelse; oppdaterer til {version}, som ble funnet tidligere.", + "update.refresh_selected": "Oppdaterer til {version}.", "update.release_page": "Åpne utgivelsesside", "update.restart": "Start på nytt nå", "update.restart_later": "Senere", diff --git a/assets/locales/nl.json b/assets/locales/nl.json index faf69409ff..40a5b54a2d 100644 --- a/assets/locales/nl.json +++ b/assets/locales/nl.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Ontwerpen die u opent of opslaat, verschijnen hier.", "Determined by To Object. Choose Angle to edit the angular extent.": "Bepaald door Tot object. Kies Hoek om de hoekomvang te bewerken.", "Diameter": "Diameter", + "Did this assembly open the wrong way up?": "Is deze samenstelling verkeerd om geopend?", + "Did this part open the wrong way up?": "Is dit onderdeel verkeerd om geopend?", "Dimension new shapes": "Nieuwe vormen maatvoeren", "Direction": "Richting", "Discard": "Verwerpen", @@ -204,6 +206,7 @@ "Edges": "Randen", "Edit": "Bewerken", "Edit Profile Sketch": "Profielschets bewerken", + "Edit shortcuts": "Sneltoetsen bewerken", "Enable rule": "Regel inschakelen", "Enabled": "Ingeschakeld", "End Radius": "Eindstraal", @@ -466,6 +469,7 @@ "Radius": "Straal", "Radius Fit": "Straalaanpassing", "Ratio": "Verhouding", + "Read-only list: shortcuts can't be changed here.": "Alleen-lezen lijst: sneltoetsen kunnen hier niet worden gewijzigd.", "Reattach selected face": "Geselecteerd vlak opnieuw koppelen", "Recover": "Herstellen", "Recover unsaved design?": "Niet-opgeslagen ontwerp herstellen?", @@ -599,6 +603,7 @@ "Square": "Vierkant", "Stand it up": "Rechtop zetten", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Rechtop zetten: een kwartslag om X, als één Verplaatsen-kenmerk dat u ongedaan kunt maken.", + "Stand this assembly up?": "Deze samenstelling rechtop zetten?", "Stand this part up?": "Dit onderdeel rechtop zetten?", "Start angle": "Beginhoek", "Start bulge": "Beginbolling", @@ -649,6 +654,7 @@ "Third point": "Derde punt", "This child is shared. Deleting it removes it from every copy of its parent.": "Dit onderliggende item wordt gedeeld. Verwijderen haalt het uit elke kopie van het bovenliggende item.", "This child is shared. Position changes apply to every copy of its parent.": "Dit onderliggende item wordt gedeeld. Positiewijzigingen gelden voor elke kopie van het bovenliggende item.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Dit component zit in een subsamenstelling die meer dan eens is geplaatst: uitlijnen verplaatst het in elke plaatsing.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Dit vlak kan geen duurzame bevestiging bieden. Kies een ander vlak of gebruik expliciet een vast vlak.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Deze afbeelding is nog niet aan GitHub toegevoegd. Kopieer of sla de afbeelding op en voeg die daarna op GitHub toe.", "Thread depth": "Schroefdraaddiepte", @@ -769,6 +775,8 @@ "at the face": "op het vlak", "bom.group_children": "Subonderdelen in de rij van hun onderdeel", "browser.body_name": "Lichaam{number}", + "browser.calibrate_click_a": "Klik op de afbeelding voor Punt A of typ de coördinaten ervan.", + "browser.calibrate_click_b": "Klik op de afbeelding voor Punt B of typ de coördinaten ervan.", "browser.calibrate_title": "Kalibreren: {name}", "browser.capture_many": "{count} componenten zijn verplaatst, maar hun posities zijn niet vastgelegd.", "browser.capture_note": "Vastleggen behoudt de nieuwe positie; Terugzetten zet deze terug.", @@ -790,6 +798,11 @@ "cam.roller": "Rol", "cam.roller_pick": "Rol (optioneel)", "cam.tip_pick": "Punt (optioneel)", + "camera.shot_pitch": "Kanteling", + "camera.shot_roll": "Rollen", + "camera.shot_yaw": "Gieren", + "canvas.decal_place_hint": "Klik op een vlak om de sticker te plaatsen", + "canvas.place_hint": "Klik op een werkvlak of vlak om het canvas te plaatsen", "choose a profile": "kies een profiel", "choose the cam's and the follower's joints": "kies de verbindingen van de nok en de volger", "choose the joints to couple": "kies de te koppelen verbindingen", @@ -797,12 +810,16 @@ "click sketch curves": "klik op schetscurven", "cmd.appearance_assign": "Uiterlijk", "cmd.appearance_chain": "Uiterlijkenketen", + "cmd.appearance_copy": "Uiterlijk kopiëren", + "cmd.appearance_favorite": "Uiterlijk als favoriet", "cmd.appearance_library": "Uiterlijkbibliotheek", "cmd.appearance_list": "Uiterlijken weergeven", "cmd.appearance_paint": "Uiterlijk aanbrengen", + "cmd.appearance_paste": "Uiterlijk plakken", "cmd.appearance_pick_up": "Uiterlijk oppakken", "cmd.appearance_replace": "Uiterlijk vervangen", "cmd.assembly_bom": "Stuklijst", + "cmd.body.print_face": "Printvlak", "cmd.body_rename": "Lichaam hernoemen", "cmd.browser_collapse_all": "Alles samenvouwen", "cmd.browser_expand_all": "Alles uitvouwen", @@ -810,6 +827,7 @@ "cmd.browser_move": "Browseritems verplaatsen", "cmd.browser_order": "Browseritems ordenen", "cmd.browser_rename_group": "Groep hernoemen", + "cmd.browser_reparent": "Naar component verplaatsen", "cmd.browser_ungroup": "Groep opheffen", "cmd.browser_visibility": "Tonen/verbergen", "cmd.canvas_calibrate": "Canvas kalibreren", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Onderdeeleigenschappen", "cmd.component_metadata_set": "Onderdeeleigenschappen wijzigen", "cmd.component_move_bodies": "Lichamen naar component verplaatsen", + "cmd.component_move_features": "Kenmerken naar component verplaatsen", "cmd.component_move_sketches": "Schetsen naar component verplaatsen", + "cmd.component_paste_from_design": "Plakken uit een ander ontwerp", "cmd.component_paste_new": "Nieuw plakken", "cmd.component_properties": "Fysieke eigenschappen component", "cmd.component_relink": "Bron opnieuw koppelen", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Bewegingstraject", "cmd.motion_trace_edit": "Bewegingstraject bewerken", "cmd.motion_trace_keep": "Bewegingstraject behouden", + "cmd.occurrence_align": "Occurrence uitlijnen", "cmd.occurrence_copy": "Plakken (instantie)", "cmd.occurrence_drag": "Occurrence slepen", "cmd.occurrence_ground": "Vastzetten", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Oppervlaktekoppeling verwijderen", "cmd.render_library_targets": "Doelen in renderbibliotheek zoeken", "cmd.render_lights_add": "Licht toevoegen", + "cmd.render_lights_duplicate": "Renderlicht dupliceren", + "cmd.render_lights_list": "Renderlichten weergeven", + "cmd.render_lights_mute": "Licht dempen", + "cmd.render_lights_remove": "Renderlicht verwijderen", + "cmd.render_lights_set": "Renderlicht instellen", "cmd.render_lights_solo": "Licht solo", "cmd.render_optics_bind": "Optische koppeling opslaan", "cmd.render_optics_unbind": "Optische koppeling verwijderen", "cmd.render_output_size": "Renderuitvoergrootte instellen", "cmd.render_preflight": "Renderverzoek controleren", + "cmd.render_preflight_copy": "Rapport van rendercontrole kopiëren", "cmd.render_preset_apply": "Ingebouwde studio toepassen", "cmd.render_preset_resolve": "Ingebouwde studio controleren", "cmd.render_quality_prepare": "Vast samplingbudget controleren", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Afbeelding renderen", "cmd.render_status": "Rendervoortgang", "cmd.select_clear": "Selectie wissen", + "cmd.settings_profile_apply": "Instellingenprofiel toepassen", + "cmd.settings_profile_delete": "Instellingenprofiel verwijderen", + "cmd.settings_profile_export": "Instellingenprofiel exporteren", + "cmd.settings_profile_import": "Instellingenprofiel importeren", + "cmd.settings_profile_list": "Instellingenprofielen", + "cmd.settings_profile_rename": "Instellingenprofiel hernoemen", + "cmd.settings_profile_save": "Instellingenprofiel opslaan", "cmd.sheet_convert": "Converteren naar plaatwerk", "cmd.sheet_export_bend_table": "Buigtabel exporteren", "cmd.sheet_export_dxf": "Uitslag exporteren als DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Opnieuw vouwen", "cmd.sheet_rules": "Plaatwerkregels weergeven", "cmd.sheet_unfold": "Ontvouwen", + "cmd.sketch.construction_line": "Constructielijn", + "cmd.sketch.construction_line.angle": "Constructielijn onder hoek", + "cmd.sketch.construction_line.bisector": "Bissectrice", + "cmd.sketch.construction_line.horizontal": "Horizontale constructielijn", + "cmd.sketch.construction_line.parallel": "Parallelle constructielijn", + "cmd.sketch.construction_line.perpendicular": "Loodrechte constructielijn", + "cmd.sketch.construction_line.tangent_at": "Raaklijn in punt op kromme", + "cmd.sketch.construction_line.tangent_external": "Raaklijn door extern punt", + "cmd.sketch.construction_line.tangent_two": "Gemeenschappelijke raaklijn", + "cmd.sketch.construction_line.vertical": "Verticale constructielijn", "cmd.sketch_arc3d": "3D-boog", "cmd.sketch_arc_center_point": "Boog met middelpunt", "cmd.sketch_arc_tangent": "Tangentiële boog", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Tekeninggrootte", "cmd.sketch_edit": "Schets bewerken", "cmd.sketch_edit_text": "Tekst bewerken", + "cmd.sketch_equation_curve": "Vergelijkingscurve", + "cmd.sketch_equation_curve_edit": "Vergelijkingscurve bewerken", "cmd.sketch_export_dxf": "Opslaan als DXF", "cmd.sketch_finish_auto_constrain": "Afronden met autom. beperken", "cmd.sketch_fit_curves_to_section": "Curven aanpassen aan meshdoorsnede", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "Renderbrowser", "cmd.view.render_dock": "Renderdock verplaatsen", "cmd.view.render_focus": "Renderfocusweergave", + "cmd.view.status_items": "Statusitems in de viewport", "cmd.view_ambient_occlusion": "Ambient occlusion", "cmd.view_angle_readout": "Weergavehoeken", "cmd.view_apply": "Benoemde weergave herstellen", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Loodrecht op selectie", "cmd.view_previous_tab": "Vorig tabblad", "cmd.view_rename": "Benoemde weergave hernoemen", + "cmd.view_render_frame": "Hulplijnen voor renderkader", "cmd.view_right": "Rechteraanzicht", "cmd.view_save": "Nieuwe benoemde weergave", "cmd.view_shots_strip": "Opnamestrook", @@ -1229,10 +1277,14 @@ "color.copy": "Klik om kleurwaarden te kopiëren", "color.edit": "Klik om kleur te bewerken", "color.gamma": "Kleurwaarden worden getoond in gammaruimte 0-255", + "color.hex": "Hex", + "color.hex_help": "Voer #rrggbb of #rrggbbaa in; met zes cijfers blijft de dekking behouden", + "color.hex_hint": "#rrggbb", "color.hue": "Tint", "color.linear": "Kleurwaarden worden getoond in lineaire ruimte 0-1", "color.saturation_value": "Verzadiging en waarde", "color.selected": "Geselecteerde kleur", + "config.column_filter_empty": "Geen overeenkomende kolommen", "contact.components_count": "{count} componenten", "contact.enabled_label": "ingeschakeld", "coupling.gear_1": "Tandwiel 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Klik op een andere aandrijvende maat om de naam ervan in te voegen", "dialog.dimension_name": "Maat: {name}", "dialog.edit_title": "{title} BEWERKEN", + "dialog.no_shown_body": "Geen zichtbaar lichaam om te wijzigen: toon er een of kies Nieuw lichaam", "dialog.select": "↖ Selecteren", "dialog.selected_count": "↖ {count} geselecteerd", "dialog.selection_guidance": "Selecteer een invoer om deze te wijzigen. Klik op geselecteerde objecten om ze te verwijderen; Ctrl/Cmd-klik schakelt ze ook om. Controleer het voorbeeld en druk daarna op OK.", @@ -1346,8 +1399,20 @@ "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.mit": "MIT-licentie", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Maak een oneindige horizontale constructielijn door een punt. De horizontale beperking blijft gelden bij latere bewerkingen.", + "help.sketch.construction_line.parallel": "Kies een rechte schetslijn en plaats dan een parallelle, oneindige constructielijn. Klik met rechts om een andere referentie te kiezen.", + "help.sketch.construction_line.perpendicular": "Kies een rechte schetslijn en plaats dan een loodrechte, oneindige constructielijn. Klik met rechts om een andere referentie te kiezen.", + "help.sketch.construction_line.tangent_at": "Maak een raaklijn in een punt van een cirkel of cirkelboog, of in een eindpunt van een open spline of kegelsnede. Kies de kromme en dan het raakpunt.", + "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.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.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.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.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.", @@ -1476,6 +1541,21 @@ "joint.one_component": "Deze onderdelen zijn lichamen van één component: {bodies}. Een verbinding beweegt het ene component ten opzichte van het andere, dus elk onderdeel heeft een eigen component nodig.", "joint.rigid_group_one_component": "Deze {count} lichamen bevinden zich allemaal in één component (of in het ontwerp zelf): gebruik eerst {command}.", "joint.rigid_group_select_components": "Selecteer twee of meer componenten.", + "joint_move.blocked_hint": "Controleer het verbindingstype (zoals Star), het vastzetten, de verbindingslimieten en door parameters aangestuurde waarden.", + "joint_move.blocked_title": "Dit onderdeel kan niet worden verplaatst", + "joint_move.coarse": "Rotatiestap dicht bij de as (°)", + "joint_move.distance": "Afstand vanaf het begin", + "joint_move.done": "Gereed", + "joint_move.fine": "Rotatiestap ver van de as (°)", + "joint_move.fixed": "Dit onderdeel heeft geen beweegbare verbindingsas.", + "joint_move.help": "Verplaatsen — sleep een pijl of ring. Esc annuleert het huidige slepen.", + "joint_move.legacy_hint": "Schakel uit om het vorige Verplaatsen-paneel te gebruiken.", + "joint_move.precision": "Trek buiten de gestreepte hulplijn voor fijne rotatie.", + "joint_move.rotation": "Rotatie vanaf het begin", + "joint_move.select": "Selecteer een vlak of één onderdeel van de samenstelling en kies dan Verplaatsen.", + "joint_move.settings": "Verplaatsen in samenstelling", + "joint_move.step": "Stap", + "joint_move.use_handles": "Bewegingsgrepen van verbindingen gebruiken voor Verplaatsen in samenstellingen", "keymap.command_id": "Opdracht-ID", "keymap.empty": "Geen overeenkomende opdrachten", "keymap.use_anyway": "Toch {key} hier gebruiken", @@ -1570,6 +1650,8 @@ "nav.maya.help": "Alt+linkerknop slepen draait, Alt+middenknop pant, Alt+rechterknop zoomt; middenknop slepen pant ook", "nav.openscad.help": "Linkerknop slepen op lege ruimte draait, Shift+linkerknop slepen selecteert met een kader, schetsgeometrie slepen bewerkt die; rechter- of middenknop slepen pant, Shift+rechter- of Shift+middenknop slepen zoomt", "nav.perspective.sketch": "Schetsbewerking gebruikt een orthografische weergave. Rond de schets af om naar perspectief te schakelen.", + "nav.right_drag_orbits.help": "Uit: rechts slepen laat de camera met rust en de rechtermuisknop opent alleen het contextmenu. Het kiezen van de SolidWorks-muistoewijzing schakelt dit uit.", + "nav.right_drag_orbits.label": "Rechts slepen roteert", "nav.solidworks.help": "Middenknop slepen draait, Ctrl+middenknop pant, Shift+middenknop zoomt, rechterknop slepen draait", "nav.tinkercad.help": "Rechterknop slepen draait, middenknop of Shift+rechterknop slepen pant", "nav.touchpad.help": "Houd Shift ingedrukt en beweeg de aanwijzer om te pannen; houd Alt/Option ingedrukt en beweeg om te roteren. Er is geen muisknop nodig. Verticaal scrollen zoomt.", @@ -1591,6 +1673,9 @@ "navbar.custom.customise": "Navigatiebalk aanpassen…", "navbar.custom.design": "Ontwerp", "navbar.custom.duplicate": "Dit item staat al op de balk.", + "navbar.custom.edge": "Rand", + "navbar.custom.edge_bottom": "Onder", + "navbar.custom.edge_top": "Boven", "navbar.custom.fade": "Vervagen", "navbar.custom.full": "Verwijder een item om een ander toe te voegen.", "navbar.custom.hidden_design": "Verborgen in Ontwerp", @@ -1697,12 +1782,15 @@ "parts.dimension.thickness": "Dikte", "parts.dimension.width": "Breedte", "parts.family.bearing": "Groefkogellager (omhullende)", + "parts.family.button_head_cap_screw": "Bolkopschroef met inbus", "parts.family.dowel_pin": "Parallelpen (paspen)", "parts.family.hex_bolt": "Zeskantbout / zeskantschroef", "parts.family.hex_nut": "Zeskantmoer", "parts.family.set_screw": "Stelschroef met inbus, vlakke punt", "parts.family.socket_head_cap_screw": "Cilinderkopschroef met inbus", "parts.family.washer": "Vlakke onderlegring", + "parts.holes": "Gaten (optioneel)", + "parts.placement_hint": "kies een of meer gaten om in elk een onderdeel te plaatsen, of OK plaatst het in de oorsprong", "pattern.alignment_2": "Uitlijning 2", "pattern.alignment_hint": "Aantallen omvatten het origineel. Gecentreerd verdeelt kopieën gelijkmatig; Omkeren kiest de extra zijde bij even totalen.", "pattern.direction_1": "Richting 1", @@ -1743,6 +1831,8 @@ "prefs.anti_aliasing_note": "Maakt kartelige randen glad.", "prefs.applies_on_restart": "Wordt toegepast na herstart", "prefs.assistant": "Assistent", + "prefs.auto_operation": "De bewerking automatisch kiezen", + "prefs.auto_operation_note": "De dialoogvensters van kenmerken kiezen Samenvoegen, Nieuw lichaam of Knippen op basis van wat het nieuwe materiaal raakt, totdat je er zelf een kiest. Uit: ze houden de bewerking aan waarmee ze openen.", "prefs.axes": "Assen", "prefs.axis": "As", "prefs.bodies_folders": "Lichamenmappen", @@ -1753,6 +1843,11 @@ "prefs.bottom_centre": "Midden onder", "prefs.bottom_right": "Rechtsonder", "prefs.browser": "Browser", + "prefs.browser_eye_left": "Oog naast de uitklappijl", + "prefs.browser_eye_left_note": "Rijen in de browser tonen hun oog om te tonen of te verbergen naast de uitklappijl, zoals voorheen, in plaats van in een kolom aan het rechteruiteinde van de rijen.", + "prefs.browser_reveal_on_hover": "Tonen bij aanwijzen", + "prefs.browser_show_note": "Getoond houdt de browser op het scherm. Tonen bij aanwijzen houdt hem uit beeld totdat de aanwijzer over zijn plek of over het smalle lipje aan zijn linkerrand komt, en hij blijft open zolang de aanwijzer erin staat of een menu ervan open is.", + "prefs.browser_shown": "Getoond", "prefs.built_in": "Ingebouwd", "prefs.button_left": "Linkerknop slepen", "prefs.button_middle": "Middenknop slepen", @@ -1800,6 +1895,8 @@ "prefs.graphics_card_restart": "Wordt van kracht na het opnieuw starten van SolidCraft. WGPU_ADAPTER_NAME heeft voorrang op deze keuze.", "prefs.graphics_note": "Wissel snelheid in voor een gladder beeld. Elke keuze toont het effect op een voorbeeld.", "prefs.graphics_presets_note": "Voorinstellingen stellen de onderstaande keuzes samen in.", + "prefs.hide_constraint_glyphs_zoomed_out": "Beperkingssymbolen verbergen bij uitzoomen", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Beperkingssymbolen verdwijnen uit beeld als er zo ver op de schets is uitgezoomd dat ze het beeld zouden overladen. Uit: ze behouden bij elk zoomniveau een vaste grootte op het scherm.", "prefs.history": "Geschiedenis", "prefs.history_origin_bodies": "Oorsprong en lichamen bovenaan tonen", "prefs.history_origin_bodies_note": "De geschiedenis links of onder de browser begint met de map Lichamen van het actieve component en de Oorsprong, met hun oogpictogrammen en menu's zoals in de browser. De geschiedenis onderaan blijft ongewijzigd.", @@ -1838,6 +1935,9 @@ "prefs.max_file_size_step": "STEP- en importbestanden", "prefs.max_file_size_web": "De webapp opent STEP- en importbestanden tot 512 MB en ontwerpen tot 256 MB: een browsertabblad kan geen grotere bestanden bevatten. In de desktopapp kunnen deze limieten worden verhoogd.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Grote pictogrammen", + "prefs.menu_list": "Lijst", + "prefs.menus": "Menu's", "prefs.minutes": "minuten", "prefs.minutes_suffix": " min", "prefs.mouse": "Muis en trackpad", @@ -1857,14 +1957,40 @@ "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.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", "prefs.placement": "Plaatsing", "prefs.prefer_free_sketch_points": "Verplaatsbare schetspunten voorrang geven als punten overlappen", "prefs.preview_choices": "Voorbeeld van je keuzes", + "prefs.print_face_highlight": "Markering van het printvlak", + "prefs.print_face_highlight_tip": "Hoe sterk het printvlak van een lichaam in de weergave wordt gekleurd en omlijnd. 0 % toont geen markering.", + "prefs.profile_active": "Actief profiel", + "prefs.profile_applied": "Profiel toegepast.", + "prefs.profile_changed": "De instellingen zijn gewijzigd sinds het profiel is opgeslagen. Opslaan bewaart ze erin.", + "prefs.profile_deleted": "Profiel verwijderd.", + "prefs.profile_export": "Exporteren…", + "prefs.profile_exported": "Profiel geëxporteerd.", + "prefs.profile_imported": "Profiel geïmporteerd. Kies het onder Actief profiel om ernaar over te schakelen.", + "prefs.profile_name_hint": "Profielnaam, bijv. Laptop", + "prefs.profile_rename": "Hernoemen", + "prefs.profile_renamed": "Profiel hernoemd.", + "prefs.profile_save": "Opslaan", + "prefs.profile_saved": "Huidige instellingen opgeslagen in het profiel.", + "prefs.profile_skipped": "Profiel toegepast; {n} instelling(en) hebben hun huidige waarde behouden (zie het logboek).", + "prefs.profiles": "Instellingenprofielen", + "prefs.profiles_note": "Sneltoetsen, de werkbalk, navigatie, het radiaalmenu en interface-instellingen, samen bewaard onder een naam en in één keer gewisseld.", + "prefs.project_options_dialog": "Dialoogvenster voor Projecteren", + "prefs.project_options_dialog_note": "Aan: kies Geometrie of Lichamen en Projectiekoppeling voordat je projecteert. Uit: de vorige werkwijze met direct kiezen behouden.", "prefs.quality_default": "standaard", "prefs.quality_fastest": "snelste", "prefs.quality_slowest": "langzaamste", "prefs.quality_smoother": "gladder", + "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", + "prefs.ribbon_reveal": "Zoekresultaten in het lint tonen", + "prefs.ribbon_reveal_note": "Als je de aanwijzer op een resultaat in Opdrachten zoeken houdt, zie je waar de opdracht in het lint staat: het tabblad, de knop of de rij in de vervolgkeuzelijst van een paneel. Een opdracht die vanuit de zoekfunctie is gestart, knippert daar, en een actieve opdracht uit een vervolgkeuzelijst markeert het paneel.", "prefs.right_click": "Rechtsklik", "prefs.saving": "Automatisch opslaan en herstel", "prefs.scale_note": "Ook Ctrl + / Ctrl − in het venster. Knoppen zijn 28 px bij 100%.", @@ -1873,18 +1999,29 @@ "prefs.search_empty": "Geen overeenkomende instellingen", "prefs.select": "Selecteren", "prefs.select_one_item": "Eén item selecteren", + "prefs.select_other_hidden": "Verborgen geometrie selecteren met Overige selecteren", + "prefs.select_other_hidden_help": "Aan: ook verborgen lichamen, schetsen en afgedekte randen bij de aanwijzer meenemen. Uit: de oorspronkelijke lijst met zichtbare geometrie onder de aanwijzer gebruiken.", "prefs.selecting": "Selecteren", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift voegt alleen toe; Ctrl / Cmd schakelt om", "prefs.shift_add_note": "Aan: Shift voegt toe, Ctrl / Cmd schakelt om. Uit (standaard): beide schakelen om.", "prefs.shortcuts": "Sneltoetsen", + "prefs.single_surface_analysis": "Eén oppervlakteanalyse tegelijk gebruiken", + "prefs.single_surface_analysis_help": "Indien uit kunnen kleuring van het oppervlak en Continuïteit tegelijk aan blijven. Als dit aan staat, blijft alleen de laatst gestarte of gewijzigde analyse actief.", + "prefs.sketch_align_points": "Uitlijnen met andere schetspunten tijdens het tekenen", + "prefs.sketch_align_points_note": "Tijdens het tekenen lijnt een punt tegelijk horizontaal uit met het ene schetspunt en verticaal met een ander, met een gestippelde hulplijn, en de klik behoudt beide uitlijningen. Uit: alleen met het vorige punt, één as tegelijk.", "prefs.sketch_conflict_colour": "Conflicterende geometrie in de conflictkleur", "prefs.sketch_conflict_colour_note": "Zolang de beperkingen van een schets conflicteren, worden ook de curven en punten die een niet-vervulde beperking vasthoudt in de conflictkleur getekend, niet alleen het symbool of de maat ervan. Uit: ze houden hun gewone kleuren.", + "prefs.sketch_construction_lines": "Uitgebreide tools voor constructielijnen", "prefs.sketch_exit_view": "Wanneer een schets eindigt", "prefs.sketch_exit_view.keep": "De huidige weergave behouden", "prefs.sketch_exit_view.previous": "Terug naar de vorige weergave", "prefs.sketch_exit_view.unless_rotated": "Terug, tenzij geroteerd", "prefs.sketch_exit_view_note": "Waar de weergave naartoe gaat na Schets afronden, Annuleren of een dubbele Esc.", + "prefs.sketch_open_look_at": "Naar een schets toe draaien wanneer die wordt geopend", + "prefs.sketch_open_look_at_note": "Uit: een schets maken of bewerken laat de weergave zoals die is. Kijken naar richt de weergave op de schets wanneer je dat wilt.", + "prefs.sketch_recent_snap": "Automatisch snappen op recente cirkelmiddens en middelpunten", + "prefs.sketch_recent_snap_note": "Tijdens het tekenen snapt de cursor op het punt waar de hulplijnen van de twee meest recente cirkelmiddens of middelpunten van rechte randen elkaar kruisen. Uit: die hulplijnen worden alleen getoond en de cursor beweegt niet.", "prefs.sketch_selection": "Schets en selectie", "prefs.sketch_selection_heading": "Schets en selectie", "prefs.sketch_selection_note": "Hoe schetsen snappen en hoe klikken selecteren.", @@ -1899,6 +2036,11 @@ "prefs.source": "Bron", "prefs.speed": "Snelheid", "prefs.stand_up_note": "Nadat een STEP- of IGES-bestand met Z omhoog is geopend, een kwartslag om X aanbieden.", + "prefs.stand_up_unknown": "Voor bestanden uit onbekende programma's", + "prefs.stand_up_unknown.ask": "Vragen in een dialoogvenster", + "prefs.stand_up_unknown.note": "Een melding tonen", + "prefs.stand_up_unknown.off": "Niet aanbieden", + "prefs.stand_up_unknown_note": "Hoe het aanbod wordt gedaan als de header van het bestand geen programma noemt waarvan bekend is dat het met Y omhoog opslaat. Een melding wordt alleen getoond als het bestand op zijn rug lijkt te liggen; Vragen in een dialoogvenster vraagt dat voor elk bestand van dat soort, zoals voorheen.", "prefs.status_bar": "Statusbalk", "prefs.status_bar_note": "Een smalle balk langs de onderrand van het venster toont wat er wordt bewerkt, de staat van de open schets in zijn kleur en de eenheden van het ontwerp; een klik op de eenheden wijzigt ze. De staat van de schets staat dan niet meer in de hoek van de weergave.", "prefs.status_done": "Gereed", @@ -1924,7 +2066,14 @@ "prefs.toast_place": "Meldingen", "prefs.toggle_item": "Het item toevoegen of weer weghalen", "prefs.toolbar": "Werkbalk", + "prefs.toolbar_tabs": "Werkbalktabbladen", "prefs.top": "Boven", + "prefs.trackpad_zoom_modifier": "Zoomen met scrollen met twee vingers bij ingedrukte toets", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Houd alleen deze toets ingedrukt om met verticaal scrollen bij de aanwijzer te zoomen. Uit behoudt het bestaande scrollgedrag.", + "prefs.trackpad_zoom_modifier.off": "Uit", "prefs.translucent": "Doorschijnend", "prefs.under_browser": "Onder de browser", "prefs.under_cursor": "Onder de cursor", @@ -1934,13 +2083,24 @@ "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.", "prefs.viewport": "Weergavevenster", + "prefs.viewport_analysis_status": "Analysestatus van de viewport tonen", + "prefs.viewport_analysis_status_help": "Toon actieve analyses boven de viewport, open hun legenda's vanuit elk item en gebruik Esc om de nieuwste analyse uit te schakelen. Schakel uit om de legenda's in de hoeken en het vorige Esc-gedrag te herstellen.", + "prefs.viewport_background": "Achtergrond", + "prefs.viewport_background_colour": "Kleur", + "prefs.viewport_background_theme": "Verloop van het thema", "prefs.wheel": "Wiel", "prefs.wheel_trackpad": "Wiel en trackpad", "prefs.workspace": "Werkruimte", + "prefs.zoom_stop_at_model": "Zoomen stoppen bij het model", + "prefs.zoom_stop_at_model_tip": "De 3D-muis en de apparaat-app zoomen naar het oppervlak in het midden van de weergave; slepend zoomen houdt het punt vast waar het slepen begon. Uit: zoomen kan door het model heen gaan. Wiel en knijpen gebruiken Naar de cursor zoomen apart", "prefs.zoom_toward_cursor": "Zoomen naar de cursor", "properties.title": "Eigenschappen: {name}", "recovery.days_ago": "{count} dagen geleden", "recovery.detail": "{file} · {features} kenmerken · automatisch opgeslagen {when}", + "recovery.discard_all": "Alles verwerpen", + "recovery.discard_all_detail": "Dit kan niet ongedaan worden gemaakt.", + "recovery.discard_all_title": "{count} automatische opslagen verwerpen?", + "recovery.discard_all_title_one": "1 automatische opslag verwerpen?", "recovery.hours_ago": "{count} u geleden", "recovery.just_now": "zojuist", "recovery.min_ago": "{count} min geleden", @@ -2025,6 +2185,7 @@ "render.appearance_editor.weight": "Gewicht", "render.appearance_physics_separate": "Het visuele uiterlijk kent geen dichtheid, thermische gegevens of andere technische eigenschappen toe.", "render.appearances": "Uiterlijken", + "render.assign_help": "Klik om op de selectie toe te passen; sleep voor een voorbeeld en laat los om neer te zetten, of klik zonder selectie om te schilderen.", "render.availability_native": "Ingebouwde samenstelling; controleren voor het toepassen", "render.availability_projection": "Ondersteunde projectie; zie bereik en weglatingen", "render.availability_reference": "Referentiegegevens; geen automatische toewijzing", @@ -2063,6 +2224,7 @@ "render.bloom_threshold": "Lineaire highlightdrempel", "render.browse_capabilities": "Volledige catalogus met mogelijkheden bekijken", "render.browse_environments": "Originele HDR-omgevingen doorbladeren", + "render.browse_looks": "Bladeren…", "render.browse_native_scenes": "Ingebouwde studio's doorbladeren", "render.browse_physical": "Fysieke referenties bekijken", "render.browse_quality": "Kwaliteitsspecificaties bekijken", @@ -2086,6 +2248,82 @@ "render.check_opaque": "Transparantie: alleen ondoorzichtige oppervlakken; achtergrondalfa wordt ondersteund.", "render.check_sorted_alpha": "Transparantie: benadering met gesorteerde alfa.", "render.check_transmission": "Transparantie: fysieke transmissie.", + "render.children.absolute_floor": "Absolute ondergrens", + "render.children.adaptive": "Adaptief", + "render.children.add_look_reason": "Voeg een look toe om de aan/uit-status en sterkte ervan te bewerken.", + "render.children.all_shots": "Alle opnamen", + "render.children.bloom": "Bloom", + "render.children.check": "Controleren", + "render.children.check_now": "Nu controleren", + "render.children.chromatic": "Chromatisch", + "render.children.compact": "Compact", + "render.children.contrast": "Contrast", + "render.children.copy_image": "Kopiëren", + "render.children.copy_job": "Taak-JSON kopiëren", + "render.children.copy_request": "Aanvraag-JSON kopiëren", + "render.children.coverage_error": "Bedekkingsfout", + "render.children.cpu": "CPU-raytracing", + "render.children.denoise": "Ruis verminderen in de voltooide afbeelding", + "render.children.depth": "Paddiepte", + "render.children.distortion": "Vervorming", + "render.children.edge_samples": "Randsamples", + "render.children.effects_banner": "Deze effecten vereisen GPU-pathtracing. Dit openen verandert de methode nooit.", + "render.children.enable_adaptive": "Schakel onafhankelijke pilots in om de adaptieve limieten te bewerken.", + "render.children.enable_bloom": "Schakel bloom in om de sterkte, drempel en straal ervan te bewerken.", + "render.children.finishing": "Afwerking", + "render.children.flare": "Flare", + "render.children.fresnel": "Fresnel-strata", + "render.children.ghosts": "Spookringen", + "render.children.glass": "Glastransport", + "render.children.go_to": "Ga naar {editor}", + "render.children.grain": "Korrel", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Ruwe lineaire master bewaren", + "render.children.last_result": "Laatste: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Lenseffecten", + "render.children.look_summary": "{tone} · {count} looks", + "render.children.looks": "Creatieve looks", + "render.children.memory": "Geheugen", + "render.children.method": "Methode", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Vereist CPU-raytracing. Dit openen verandert de methode nooit.", + "render.children.nested": "Gecertificeerde geneste diëlektrica", + "render.children.not_checked": "Niet gecontroleerd", + "render.children.path": "Pathtracing", + "render.children.pilots": "Pilotsamples", + "render.children.preview": "Voorbeeldgegevens", + "render.children.progressive_reason": "Kies Progressief om het aantal samples en de seed ervan te bewerken.", + "render.children.radius": "Straal", + "render.children.reflect_depth": "Reflectiediepte", + "render.children.reflect_samples": "Reflectiesamples", + "render.children.regrade": "Nieuwe kleurcorrectie", + "render.children.relative_error": "Relatieve fout", + "render.children.render": "Renderen", + "render.children.render_shot": "Opname renderen", + "render.children.renderer": "Renderer", + "render.children.renderers": "Renderers", + "render.children.request": "Deze aanvraag", + "render.children.reset_finishing": "Afwerking herstellen", + "render.children.review_quality": "Een kwaliteitsspecificatie beoordelen…", + "render.children.rgb_gain": "RGB-versterking", + "render.children.samples": "Samples", + "render.children.sampling": "Sampling", + "render.children.saturation": "Verzadiging", + "render.children.scene_summary": "{bodies} lichamen · {triangles} driehoeken", + "render.children.seed": "Seed", + "render.children.sharpening": "Verscherping", + "render.children.single_image": "Enkele afbeelding", + "render.children.soft_focus": "Zachte focus", + "render.children.streak": "Streep", + "render.children.strength": "Sterkte", + "render.children.studio": "Studio", + "render.children.threshold": "Drempel", + "render.children.tone": "Toon", + "render.children.under_hood": "Onder de motorkap", + "render.children.vignette": "Vignettering", + "render.children.workstation": "Werkstation", + "render.choose_look": "Kies eerst een uiterlijk in Uiterlijken", "render.chromatic_offset": "Chromatische randverschuiving", "render.clear_stage": "Opgeslagen decor verwijderen", "render.copy_request": "JSON kopiëren", @@ -2337,8 +2575,33 @@ "render.flare_strength": "Flare-sterkte voor highlights", "render.flare_threshold": "Flare-drempel (scènelineair)", "render.fold_browser": "Browser samenvouwen", + "render.frame_aspect": "Beeldverhouding", + "render.frame_centre_mark": "Middenmarkering", + "render.frame_copy_limit": "Geen beschikbare naam voor de kopie van de opname", + "render.frame_copy_name": "{name} kopie {number}", + "render.frame_custom": "Aangepast", + "render.frame_distance_unit": " × grootte", + "render.frame_duplicate": "Dupliceren", + "render.frame_exposure": "Belichting", + "render.frame_fov": "Gezichtsveld", + "render.frame_guides": "Hulplijnen", + "render.frame_invalid_aspect": "Ongeldige voorinstelling voor beeldverhouding", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Naam", + "render.frame_output_size": "Uitvoergrootte", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Portret", "render.frame_presets": "Kadervoorinstellingen", "render.frame_presets_pending": "Komt met de Opname-editor", + "render.frame_projection": "Projectie", + "render.frame_render_shot": "Deze opname renderen", + "render.frame_rule_thirds": "Regel van derden", + "render.frame_safe_area": "Veilig gebied", + "render.frame_shot_title": "Opname · {name}", + "render.frame_square": "1:1 · Vierkant", + "render.frame_tall": "9:16 · Verticaal", + "render.frame_thirds": "Derden", + "render.frame_wide": "16:9 · Breedbeeld", "render.gpu_depth": "Maximale paddiepte", "render.gpu_options_help": "Samples, diepte, seed en belichting worden opgeslagen bij deze opstelling. Alleen de actieve beeldmethode wordt opgeslagen.", "render.gpu_path_failed": "GPU-pathtracer niet beschikbaar", @@ -2366,6 +2629,7 @@ "render.heading_results": "RESULTATEN", "render.heading_shots": "OPNAMEN", "render.hide_shots_label": "Opnamestrook verbergen", + "render.hover_look": "{object} · {look} · klik om te selecteren", "render.image_advanced": "Geavanceerde GPU-instellingen", "render.image_cancelled": "Render geannuleerd", "render.image_cancelling": "Annuleren", @@ -2512,17 +2776,95 @@ "render.library_uniform_ack": "De uniforme benadering gebruiken zonder ruimtelijke of geavanceerde effecten", "render.library_usable": "Uitvoerbare projecties", "render.library_users": "Gebruikt door", + "render.light_aim": "Richten op", "render.light_anchor_pending": "Het wijzigen van het coördinatensysteem van het licht wordt nog niet ondersteund.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Kies CPU-raytracing of een expliciete GPU-studio om lichten te bewerken. Oude GPU-studiolichten blijven alleen-lezen.", + "render.light_behaviour": "GEDRAG", + "render.light_default_fill": "Vullicht", + "render.light_default_key": "Hoofdlicht", + "render.light_default_number": "Licht {number}", + "render.light_default_rim": "Randlicht", + "render.light_delete": "Verwijderen", + "render.light_distance": "Afstand", + "render.light_duplicate": "Dupliceren", + "render.light_elevation": "Elevatie", + "render.light_gpu_punctual": "Punt- en spotlichten zijn beschikbaar met CPU-raytracing.", + "render.light_hard_shadow": "Dit licht heeft harde schaduwen; zachtheid van de lichtbron is niet beschikbaar.", + "render.light_intensity": "Intensiteit", + "render.light_missing": "Dit licht bestaat niet meer. Selecteer een licht in de browser.", + "render.light_place_highlight": "Plaatsen via highlight", + "render.light_place_pending": "Plaatsen via highlight vereist het kiezen van lichten in de viewport en wordt nog niet ondersteund.", + "render.light_position": "POSITIE", + "render.light_preview_help": "De live weergave heeft geen schaduwen; vlaklichten gebruiken een benadering met één sample in het midden. Renderen gebruikt het volledige lichtmodel.", + "render.light_product_centre": "Productmidden", + "render.light_size": "Grootte", + "render.light_softness": "Zachtheid", + "render.light_temperature": "Kleurtemperatuur", + "render.light_temperature_help": "Benaderde zwartlichaamtint; opgeslagen als lineaire RGB.", + "render.light_type": "Type", + "render.light_type_area": "Oppervlak", + "render.light_type_point": "Punt", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Zon", + "render.light_view_only": "Solo en dempen gelden voor deze weergave en de renders ervan; ze worden nooit in het ontwerp opgeslagen.", + "render.light_world_origin": "Wereldoorsprong", + "render.lighting_aim_point": "Richtpunt", + "render.lighting_apply": "Voorinstelling toepassen", + "render.lighting_area_samples": "Samples voor vlaklichten", + "render.lighting_budget": "Budget", + "render.lighting_budget_help": "Aantallen zijn alleen-lezen. Voeg lichten toe, dupliceer of verwijder ze om de aantallen te wijzigen; de methode bepaalt de limieten.", + "render.lighting_colour": "Kleur", + "render.lighting_consent": "De richtingslichten vervangen door deze voorinstelling", + "render.lighting_custom": "Aangepaste lichten", + "render.lighting_custom_help": "Als je Aangepast kiest, wordt de voorinstelling omgezet in bewerkbare lichten, elk met een eigen rij in de boom.", + "render.lighting_direction": "Richting X Y Z", + "render.lighting_direction_help": "Of typ een richting naar het licht (X Y Z).", + "render.lighting_distance_unit": " × grootte", + "render.lighting_edge_u": "Zijvector U", + "render.lighting_edge_v": "Zijvector V", + "render.lighting_falloff": "Afname", + "render.lighting_falloff_help": "De intensiteit geldt op de referentieafstand.", + "render.lighting_height": "Hoogte V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Dit licht dempen", + "render.lighting_point": "Punt", + "render.lighting_preset": "Studiovoorinstelling", + "render.lighting_preset_cpu": "Studiovoorinstellingen met richtingslichten vereisen CPU-raytracing. GPU-verlichting gebruikt expliciete zon- en vlaklichten.", + "render.lighting_preset_row": "Voorinstelling", + "render.lighting_radiance": "Straling", + "render.lighting_reference": "Referentie", + "render.lighting_review_help": "Vervangt de lijst met richtingslichten. Behoudt punt- en vlaklichten, omgeving, schaduwen, camera en alle andere opname-instellingen.", + "render.lighting_review_title": "Verlichtingsvoorinstelling controleren", + "render.lighting_samples_cpu": "Samples voor vlaklichten gelden voor CPU-raytracing.", + "render.lighting_samples_need_area": "Voeg een CPU-vlaklicht toe om samples voor vlaklichten in te stellen.", + "render.lighting_samples_reason": "Samples voor vlaklichten moeten 1, 4, 16 of 64 zijn.", + "render.lighting_shadow_cpu": "De schaduwschakelaar vereist CPU-raytracing. GPU-pathtracing werpt altijd schaduwen.", + "render.lighting_shadows": "Schaduwen werpen", + "render.lighting_shadows_section": "Schaduwen en sampling", + "render.lighting_shape": "Vorm", + "render.lighting_solo": "Dit licht solo", + "render.lighting_source": "Bron", + "render.lighting_source_section": "Lichtbron", + "render.lighting_spot_preserved": "Dit puntlicht behoudt zijn opgeslagen spotkegel totdat het type wordt gewijzigd.", + "render.lighting_sun_distance": "De zonrichting is onafhankelijk van afstand en richtpunt.", + "render.lighting_unsupported": "niet ondersteund", + "render.lighting_used": "{used} van {limit}", + "render.lighting_width": "Breedte U", + "render.lighting_world_position": "Wereld X Y Z", "render.lights_fixed_to": "Lichten vast aan", "render.linear_help": "Bewaart een 32-bits float-RGBA-master vóór ruisonderdrukking, belichting, tone mapping, filters en achtergrond. Lineair sRGB/D65; met voorvermenigvuldigde dekking. Extra geheugen wordt expliciet toegestaan. Geen compressie of extra passes.", "render.linear_retain": "Ruwe lineaire EXR bewaren", "render.linear_unavailable": "Het geselecteerde voltooide frame heeft geen bewaarde EXR. Schakel bewaren in vóór het renderen; een oudere PNG kan de ruwe radiantie niet herstellen.", "render.look_add": "Kleurlook toevoegen", + "render.look_applied": "{name} toegepast", "render.look_bleach": "Geïnspireerd op bleach bypass", "render.look_cool": "Koel", "render.look_cyanotype": "Geïnspireerd op cyanotypie", "render.look_faded": "Vervaagde afdruk", + "render.look_material": "Materiaal", "render.look_mono": "Monochroom", + "render.look_pasted": "Uiterlijk geplakt", "render.look_sepia": "Sepia", "render.look_strength": "Sterkte van look", "render.look_teal_orange": "Teal / oranje", @@ -2598,9 +2940,11 @@ "render.output_advanced": "Geavanceerd", "render.output_backend_ready": "Gereed", "render.output_check": "Controleren", + "render.output_check_failed": "Controleren · fout", "render.output_check_help": "Voer Controleren uit om dit verzoek te bekijken zonder een render te starten.", "render.output_check_summary": "Controleren · Opmerkingen: {count}", "render.output_copy": "Kopiëren", + "render.output_copy_report": "Rapport kopiëren", "render.output_destination": "Bestemming", "render.output_destination_help": "Kies het doelbestand bij het exporteren van de voltooide afbeelding.", "render.output_dimensions": "Grootte", @@ -2610,6 +2954,7 @@ "render.output_fit": "Passend", "render.output_format": "Opmaak", "render.output_format_status": "PNG is de exacte voltooide weergaveafbeelding. Optionele GPU-EXR bewaart de ruwe scènelineaire FLOAT32-radiantie vóór weergavefilters. Exporteren vervangt nooit de voorbeeldcache of een andere taak.", + "render.output_grading": "Kleurcorrectie", "render.output_image": "Afbeelding", "render.output_largest": "max. {width} × {height}", "render.output_lock_ratio": "Beeldverhouding vergrendelen", @@ -2619,6 +2964,8 @@ "render.output_quality_waiting": "wachten op het kwaliteitscontract", "render.output_ratio_refused": "De gekoppelde afmeting overschrijdt de groottelimiet van deze methode. De vorige grootte blijft behouden; ontgrendel de verhouding of kies een kleinere waarde.", "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_ok": "Grootte OK · {width} × {height} px", "render.output_swap_orientation": "Breedte en hoogte omwisselen", "render.output_tools_inspector": "Bewerk grootte en bestandsindeling in de inspecteur Uitvoer.", @@ -2635,6 +2982,7 @@ "render.page_output": "Uitvoer", "render.page_scene": "Scène", "render.paint_label": "Verf", + "render.painting_with": "Schilderen met {name}", "render.passive_stage": "Vloer en passief decor", "render.passive_stage_help": "Eindige passieve panelen horen alleen bij gerenderde afbeeldingen. Ze voegen geen CAD-lichamen of massa toe. De plaatsing staat vast na het toepassen; als ze de grenzen van het product snijden, worden ze geweigerd. CPU levert geen diffuse weerkaatste verlichting.", "render.perspective": "Perspectiefcamera", @@ -2656,6 +3004,7 @@ "render.pick_help": "Kies Lichaam, Vlak of Component. Klik op lege ruimte om de selectie te wissen; Shift of Ctrl voegt toe of schakelt om. Markeringen verschijnen alleen in de live productweergave.", "render.pick_label": "Kiezen", "render.pick_up_label": "Overnemen", + "render.pick_up_prompt": "Klik op een object om het uiterlijk ervan over te nemen", "render.pick_waiting": "Kiezen in de productweergave wordt nog niet ondersteund.", "render.post_contrast": "Contrast", "render.post_gain_b": "Versterking blauw", @@ -2695,13 +3044,17 @@ "render.radial_distortion": "Radiale vervorming", "render.radial_fit": "Product passend maken", "render.radial_save": "Opname opslaan", + "render.recent_looks": "Recente uiterlijken", "render.regrade_action": "Ruwe master opnieuw kleurcorrigeren (geen ruisonderdrukking)", "render.regrade_help": "Maakt een afzonderlijke PNG van de gekozen voltooide ruwe master met deze presentatiebesturing. Traceert geen stralen, wijzigt de vastgelegde grootte, camera en lichten niet en overschrijft de EXR niet. Ruisonderdrukking wordt niet opnieuw toegepast: ruwe masters bewaren de bijbehorende hulpkanalen niet. Het afgeleide beeld blijft exporteerbaar via dezelfde taakbesturing.", "render.regrade_label": "Nieuwe kleurcorrectie", "render.reload_settings": "Opgeslagen instellingen opnieuw laden", + "render.remove_override": "Overschrijving verwijderen", + "render.replace_all": "Alles vervangen", "render.replace_ground": "Vervanging van het bestaande renderdecor bevestigen", "render.replace_label": "Vervangen", "render.replace_saved_surface": "Een bestaande opgeslagen koppeling voor dit doel vervangen", + "render.replace_with": "Vervangen door", "render.restore_camera": "Opgeslagen camera herstellen", "render.result_actual": "100%", "render.result_actual_help": "Eén beeldpixel per schermpixel.", @@ -2753,14 +3106,28 @@ "render.saved_surface_remove": "Opgeslagen koppeling verwijderen", "render.saved_surfaces": "Opgeslagen oppervlaktekoppelingen", "render.saved_surfaces_help": "Vastgemaakte ingebouwde maps volgen het gekozen exemplaar in lokale millimeters. Een vervangen of ontbrekend doel is een fout, nooit een automatische hertoewijzing.", + "render.scene_all_shots": "Alle opnamen ({count})", "render.scene_apply": "Beoordeelde studio toepassen", + "render.scene_batch_reason": "Alle opnamen vereist de renderwachtrij voor opgeslagen opnamen, die nog niet beschikbaar is.", + "render.scene_check_now": "Nu controleren", + "render.scene_check_passed": "Controle geslaagd · {notes} opmerkingen", + "render.scene_check_stale": "Controle verouderd · controleer opnieuw", + "render.scene_check_unchecked": "Nog niet gecontroleerd · 0 opmerkingen", "render.scene_consent": "Vervanging van verlichting, omgeving, belichting en renderdecor bevestigen", + "render.scene_light_summary": "{environment} · {lights} lichten", + "render.scene_look_summary": "{looks} uiterlijken · {overrides} overschrijvingen", + "render.scene_open_check": "Controleren openen", "render.scene_preserved_fields": "Camera, sampling, afmetingen, optische koppelingen, CAD-geometrie en technische materialen blijven behouden.", "render.scene_preserves_design": "Stelt verlichting en een passief decor samen voor het zichtbare onderwerp. Geometrie, technische materialen en de camera blijven afzonderlijk.", + "render.scene_ready": "Gereed om te renderen", + "render.scene_reminder": "Scène behoudt het ontwerp. Geometrie, technische eigenschappen en CAD worden vanuit Renderen nooit gewijzigd.", + "render.scene_reminders": "Herinneringen", + "render.scene_renderer_ready": "{renderer} gereed", "render.scene_replaces": "Vervangt", "render.scene_review": "Uitwerken en beoordelen", "render.scene_review_help": "Bekijk de volledige native compositie voordat je toepast. Niet-gekoppelde geavanceerde SCENE-specificaties worden niet door een alternatief vervangen.", "render.scene_scope_details": "Reikwijdte van de compositie", + "render.scene_stages": "Fasen", "render.search_appearances": "Zoeken in {count} uiterlijken", "render.setup_legacy_environment": "Deze oudere opstelling gebruikt verouderde omgevingsrotatie of -intensiteit. Die waarden blijven behouden; converteer ze expliciet voordat je een volledige afbeeldingstaak opslaat.", "render.setup_name": "Naam van opstelling", @@ -2773,7 +3140,22 @@ "render.shell_tab_camera": "RENDER-CAMERA", "render.shell_tab_lighting": "VERLICHTING", "render.shell_tab_scene": "RENDER-SCÈNE", + "render.shot_aperture": "Diafragma", + "render.shot_distance": "Afstand", + "render.shot_dof": "Scherptediepte", "render.shot_duplicate": "Opgeslagen opname dupliceren", + "render.shot_edit_output": "Bewerken in Uitvoer", + "render.shot_exposure_reason": "Belichting vereist CPU-raytracing of GPU-pathtracing.", + "render.shot_exposure_section": "Belichting", + "render.shot_focus_distance": "Scherpstelafstand", + "render.shot_fov_reason": "Gezichtsveld vereist perspectiefprojectie.", + "render.shot_frame_subject": "Onderwerp in beeld brengen", + "render.shot_invalid_camera": "Camerawaarden moeten eindig zijn en binnen het bereik liggen.", + "render.shot_lens": "Lens", + "render.shot_lens_enable_reason": "Schakel scherptediepte in om diafragma en scherpstelafstand te bewerken.", + "render.shot_lens_gpu_reason": "Scherptediepte vereist GPU-pathtracing.", + "render.shot_lens_perspective_reason": "Scherptediepte vereist perspectiefprojectie.", + "render.shot_look_selection": "Naar selectie kijken", "render.shot_manager": "Opgeslagen opnamen", "render.shot_new_name": "Naam van nieuwe opname", "render.shot_remove": "Opgeslagen opname verwijderen", @@ -2781,6 +3163,9 @@ "render.shot_remove_title": "Opgeslagen opname verwijderen?", "render.shot_rename": "Opgeslagen opname hernoemen", "render.shot_saved_only": "Sla eerst het huidige concept op. Dupliceren en hernoemen behouden de exacte opgeslagen opties en bronverwijzingen.", + "render.shot_selection_reason": "Selecteer een lichaam of vlak om op te richten.", + "render.shot_size": "Grootte", + "render.shot_target": "Doel", "render.shots_camera_changed": "Camera gewijzigd sinds deze opname is opgeslagen", "render.shots_name_help": "Voer een unieke naam in voor de huidige opname.", "render.shots_no_result": "Nog geen render", @@ -3003,6 +3388,13 @@ "render.wait_pick_up": "nog niet ondersteund: producten kiezen en uiterlijken overnemen", "render.wait_replace": "nog niet ondersteund: alle toepassingen van een uiterlijk vervangen", "render.wait_solo": "nog niet ondersteund: lichtzichtbaarheid en solo-bediening", + "render.wear_applies_to": "Geldt voor", + "render.wear_layer_count": "{count} lagen", + "render.wear_layer_one": "{count} laag", + "render.wear_layers": "Lagen", + "render.wear_no_layers": "Geen lagen", + "render.wear_selected_layer": "Geselecteerde laag", + "render.wear_title": "Slijtage", "sample.bracket.about": "Basisflens met randflenzen en een zoom op een regel van 2 mm", "sample.bracket.name": "Plaatwerkbeugel", "sample.enclosure.about": "Uitgeholde doos met een lip, schroefnokken, een rustvlak en een snapverbinding", @@ -3036,6 +3428,7 @@ "select an axis": "selecteer een as", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "selecteer eerst een as, een rechte of cirkelvormige rand, of een cilinder-, kegel- of torusvlak", "select bodies first": "selecteer eerst lichamen", + "select components first": "selecteer eerst componenten", "select corner points, or the two lines of one corner": "selecteer hoekpunten, of de twee lijnen van één hoek", "select one corner point, or the two lines of one corner": "selecteer één hoekpunt, of de twee lijnen van één hoek", "select sheet edges first": "selecteer eerst plaatranden", @@ -3054,6 +3447,7 @@ "settings.double_click_components": "Dubbelklik op een component om het te activeren of om een gekoppeld component ter plaatse te bewerken", "settings.frame_rate_details_note": "Voegt een grafiek toe met de tijden van de laatste frames, met de getekende driehoeken erboven, een histogram van frametijden en wat de GPU heeft getekend. Klik op de framesnelheid in de weergave om ze te tonen of te verbergen.", "settings.frame_rate_note": "Toont frames per seconde in de rechteronderhoek van de weergave.", + "settings.insert_lists_open_designs": "Ontwerp invoegen toont eerst de ontwerpen die in andere tabbladen open zijn", "settings.measure_axis_distances": "X/Y/Z-afstanden tonen", "settings.measure_axis_distances_help": "Toon de componentmaten langs de wereldassen naast de totale afstand terwijl Meten open is.", "settings.msaa_applies_on_restart": "Anti-aliasing wordt toegepast bij de volgende start van SolidCraft", @@ -3078,6 +3472,10 @@ "shortcut.unpin": "Losmaken", "sketch is missing": "schets ontbreekt", "sketch.degrees_of_freedom": "{count} vrijheidsgraden", + "sketch.dimension_added_driven": "De schets bepaalt die maat al, dus is deze toegevoegd als aangedreven maat (referentiemaat)", + "sketch.dimension_applied": "Maat toegepast", + "sketch.dimension_applied_defined": "Maat toegepast; parameter `{name}` gedefinieerd", + "sketch.dimension_applied_renamed": "Maat toegepast; parameter `{name}` genoemd", "sketch.dof_left": "{count} vrijheidsgraden resterend", "sketch.dof_left_one": "{count} vrijheidsgraad resterend", "sketch.draw_curves": "Tekenen als {linetype}", @@ -3112,6 +3510,8 @@ "sketch_text.use_font_file": "Lettertypebestand {path} gebruiken", "solid": "solid", "status.editing": "{name} wordt bewerkt", + "status.navigation": "Muistoewijzing: {mapping}", + "status.navigation_tip": "Hoe de muis de weergave verplaatst. Klik om een andere toewijzing te kiezen", "status.under_constrained": "Onderbeperkt ({count} vrijheidsgraden)", "status.under_constrained_one": "Onderbeperkt ({count} vrijheidsgraad)", "status.units_tip": "De eenheden van het ontwerp. Klik om ze te wijzigen", @@ -3145,16 +3545,20 @@ "timeline.delete_folders_tip": "Hun kenmerken en geneste mappen in de geschiedenis behouden", "timeline.delete_folders_with_features_tip": "De mappen verwijderen met alle kenmerken erin en hun geneste mappen", "timeline.delete_many": "{count} geselecteerde kenmerken verwijderen", + "timeline.edited_rolled_back": "{name} is gewijzigd, maar staat na de geschiedenismarkering en wordt dus niet opgebouwd: verplaats de markering voorbij dit kenmerk om het resultaat te zien", "timeline.feature_error": "Fout: {message}", "timeline.feature_hint": "Dubbelklik om te bewerken · sleep om te herschikken · rechtsklik voor meer", + "timeline.feature_rolled_back": "Teruggedraaid: na de geschiedenismarkering, dus niet opgebouwd", "timeline.feature_warning": "Waarschuwing: {message}", "timeline.group": "Nieuwe kenmerkmap", "timeline.move_many": "Tijdlijnitems verplaatsen", "timeline.move_to_group": "Kenmerken naar map verplaatsen", "timeline.rename_group": "Kenmerkmap hernoemen", "timeline.reorder_many": "Kenmerken herordenen", + "timeline.suppress_features": "Kenmerken onderdrukken", "timeline.suppress_folders_tip": "Alle kenmerken in de mappen en hun geneste mappen onderdrukken of de onderdrukking ervan opheffen", "timeline.ungroup": "Kenmerkmappen verwijderen", + "timeline.unsuppress_features": "Onderdrukking van kenmerken opheffen", "to the face": "tot het vlak", "to the next face": "tot het volgende vlak", "toolbar.customize_note": "Tabblad {tab}. Vink een opdracht aan om er een werkbalkknop voor te maken. U kunt ook een rij uit de vervolgkeuzelijst van een paneel naar de werkbalk slepen, een knop van de werkbalk af slepen om hem te verwijderen, of de pin op een rij van de vervolgkeuzelijst gebruiken.", @@ -3226,6 +3630,10 @@ "ui.3d_mouse_offer": "Je 3D-muis kalibreren verhelpt verwisselde of omgekeerde assen en stelt snelheid en gevoel in.", "ui.3d_mouse_offer_never": "Niet meer tonen", "ui.3d_mouse_offer_swap": "Naar Hidraw wisselen (direct)", + "ui.3d_mouse_orbit": "Roteren", + "ui.3d_mouse_orbit_classic": "Klassiek", + "ui.3d_mouse_orbit_tip": "Getraceerd: de 3D-muis draait de weergave rond het deel van het model dat midden in de weergave ligt wanneer het draaien begint, gemarkeerd door een vage bol. Klassiek: rond het midden van de weergave", + "ui.3d_mouse_orbit_traced": "Getraceerd", "ui.3d_mouse_reading": "Uitlezen", "ui.3d_mouse_reset_axes": "Astoewijzing herstellen", "ui.3d_mouse_reset_view": "Weergave resetten", @@ -3262,6 +3670,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Draai de dop tegen de klok in, houd vast en laat dan los.", "ui.3d_mouse_switch_cap": "Kalibreren als Return to Center", "ui.3d_mouse_switch_trackball": "Kalibreren als trackball", + "ui.3d_mouse_use_direct": "Direct stuurprogramma gebruiken", + "ui.3d_mouse_use_direct_tip": "De 3D-muis direct uitlezen (hidraw) in plaats van via spacenavd.", "ui.3d_mouse_view_axis": "Weergave-as", "ui.3d_mouse_view_off": "Uit", "ui.3d_mouse_view_off_tip": "Het profiel van het 3D-muisstuurprogramma schakelt deze as uit, dus de beweging ervan verplaatst de weergave niet. Kies je hier de beweging van een andere as, dan wisselen de twee van plaats en verplaatst die beweging de weergave niet meer.", @@ -3568,6 +3978,8 @@ "ui.cancel": "Annuleren", "ui.canvases": "Canvassen", "ui.center": "Midden", + "ui.center_arc_follows_cursor": "Boog met middelpunt volgt de richting van de cursor", + "ui.center_arc_follows_cursor_tip": "Een boog met middelpunt loopt met de klok mee of ertegen in, afhankelijk van hoe de cursor om het middelpunt heen beweegt. Uit: altijd tegen de klok in vanaf het begin.", "ui.centerline": "Hartlijn", "ui.chain": "Keten", "ui.chamfer": "Afschuining", @@ -3576,6 +3988,7 @@ "ui.circle": "Cirkel", "ui.circular_pattern": "Cirkelvormig patroon", "ui.clear": "Wissen", + "ui.clear_print_face": "Printvlak wissen", "ui.clear_recent": "Recent wissen", "ui.clear_the_selection": "De selectie wissen", "ui.click_selects_whole_bodies": "Klik selecteert volledige lichamen", @@ -3610,6 +4023,15 @@ "ui.constant": "Constant", "ui.constraints_conflict": "Beperkingen conflicteren", "ui.construction": "Constructie", + "ui.construction.angle_hint": "Constructielijn: klik op een punt en typ dan een hoek of kies een richting", + "ui.construction.bisector_hint": "Bissectrice: kies twee elkaar snijdende rechte lijnen en dan de gewenste oplossing", + "ui.construction.common_hint": "Gemeenschappelijke raaklijn: kies twee cirkels/bogen en dan de gewenste oplossing", + "ui.construction.contact_hint": "Raaklijn: kies een cirkel/boog of open spline/kegelsnede en dan het raakpunt (vrije krommen: eindpunten)", + "ui.construction.external_hint": "Raaklijn: kies een cirkel of boog, een extern punt en dan de gewenste oplossing", + "ui.construction.free_hint": "Constructielijn: klik op twee punten; klik met rechts om opnieuw te beginnen", + "ui.construction.point_hint": "Constructielijn: klik op het punt waar de lijn doorheen moet", + "ui.construction.reference_hint": "Constructielijn: kies een rechte lijn en dan een punt; klik met rechts om de referentie te wijzigen", + "ui.construction.ribbon_label": "Hulplijnen", "ui.context_menu": "Contextmenu", "ui.continuity_g0": "G0-knik", "ui.continuity_g1": "G1-tangentieel", @@ -3701,6 +4123,14 @@ "ui.end_scale": "Eindschaal", "ui.entities": "Elementen", "ui.equal": "Gelijk", + "ui.equation_end": "Einde", + "ui.equation_explicit": "Expliciet", + "ui.equation_form": "Vorm", + "ui.equation_note": "De variabele is een gewoon getal: sin(t) neemt het in radialen. Een resultaat zonder eenheid is in de eenheid van het ontwerp.", + "ui.equation_parametric": "Parametrisch", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Equivalente straal", "ui.expand": "Uitvouwen", "ui.exportDxf": "DXF exporteren…", @@ -3766,6 +4196,7 @@ "ui.heading_design": "ONTWERP", "ui.heading_draft": "AANZET", "ui.heading_edit": "BEWERKEN", + "ui.heading_equation_curve": "VERGELIJKINGSCURVE", "ui.heading_extrude": "EXTRUDEREN", "ui.heading_fillet": "AFRONDING", "ui.heading_finish_editing": "BEWERKEN VOLTOOIEN", @@ -3857,6 +4288,10 @@ "ui.import_ellipsis": "Importeren…", "ui.infinite": "Oneindig", "ui.insert": "Invoegen", + "ui.insert_design.browse": "Bladeren…", + "ui.insert_design.open_designs": "Open ontwerpen", + "ui.insert_design.saved_file": "{name} heeft niet-opgeslagen wijzigingen, dus is het opgeslagen bestand ingevoegd. Sla het op en gebruik daarna Koppelingen bijwerken om de wijzigingen over te nemen.", + "ui.insert_design.unsaved": "Niet-opgeslagen wijzigingen", "ui.inspect": "Inspecteren", "ui.intersect": "Snijden", "ui.isolate": "Isoleren", @@ -4000,12 +4435,29 @@ "ui.polygon": "Veelhoek", "ui.preferences": "Voorkeuren", "ui.press_a_key": "druk op een toets…", + "ui.print_face_cleared": "{body}: printvlak gewist", + "ui.print_face_input": "Printvlak", + "ui.print_face_one_body": "Een printvlak hoort bij één lichaam: stuur dat lichaam alleen om het te wijzigen", + "ui.print_face_pick": "Selecteer één vlak van een massief lichaam om het het printvlak te maken", + "ui.print_face_set": "{body}: printvlak ingesteld; Naar slicer sturen legt het lichaam erop", "ui.profile": "Profiel", "ui.profile_plane": "Profielvlak", "ui.profiles": "Profielen", "ui.project": "Projecteren", "ui.project_face_edges_sketch": "Vlakranden projecteren in nieuwe schetsen", "ui.properties": "Eigenschappen", + "ui.properties.copy_matrix": "Matrix kopiëren", + "ui.properties.first_moments": "Eerste momenten", + "ui.properties.inertia": "Traagheidsmoment", + "ui.properties.inertia_note": "Ten opzichte van het zwaartepunt, in wereldassen (g·{unit}²)", + "ui.properties.inertia_unavailable": "Niet beschikbaar: de mesh van een lichaam is niet gesloten.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Meten…", + "ui.properties.measuring_failed": "Het meten is gestopt voordat het klaar was.", + "ui.properties.principal_axis": "Hoofdas {number}", + "ui.properties.principal_moments": "Hoofdtraagheidsmomenten", "ui.pull_direction": "Trekrichting", "ui.quality": "Kwaliteit", "ui.quantity": "Aantal", @@ -4051,6 +4503,7 @@ "ui.revolve": "Revolve", "ui.ribbon_exit": "Afsluiten", "ui.ribbon_labels": "Tekst onder lintpictogrammen tonen", + "ui.ribbon_menus_skip_pinned": "Vastgepinde opdrachten weglaten uit de vervolgkeuzelijsten", "ui.ribbon_pin_footer": "Vastgepinde opdrachten verschijnen in het lint. Beweeg over een rij om vast te pinnen of los te maken.", "ui.ribbon_workspace": "Werkruimte", "ui.right": "Rechts", @@ -4105,7 +4558,11 @@ "ui.sketch": "Schets", "ui.sketch_dimension": "Schetsmaat", "ui.sketch_grid": "Schetsraster", + "ui.sketch_intersect_geometry": "Geometrie", + "ui.sketch_intersect_projection_link": "Projectiekoppeling", + "ui.sketch_intersect_selection_filter": "Selectiefilter", "ui.sketch_modifiers": "Schetsmodificatoren", + "ui.sketch_project_geometry": "Geometrie", "ui.sketch_scale": "Schetsschaal", "ui.sketches": "Schetsen", "ui.slice": "Slicen", @@ -4156,6 +4613,7 @@ "ui.tab_manage": "Beheren", "ui.tab_sheet_metal": "Plaatwerk", "ui.tab_view": "Weergave", + "ui.tab_weldments": "Laswerk", "ui.tangent": "Tangentieel", "ui.taper_angle": "Tapsheidshoek", "ui.tapped": "Met schroefdraad", @@ -4171,6 +4629,7 @@ "ui.tip_angle": "Puntshoek", "ui.to_object": "Tot object", "ui.tool_direction": "Gereedschapsrichting", + "ui.toolbar_tabs_hidden": "Alle werkbalktabbladen zijn verborgen. Klik op + of klik hier met rechts om er een te tonen.", "ui.top": "Boven", "ui.top_diameter": "Bovendiameter", "ui.top_radius": "Bovenstraal", @@ -4208,6 +4667,8 @@ "ui.view_centre": "Weergavemidden", "ui.viewport_status_clear": "{name} uitschakelen", "ui.viewport_status_clear_hint": "{name} uitschakelen · Esc", + "ui.viewport_status_edges": "{count} randen", + "ui.viewport_status_from": "vanaf {direction}", "ui.viewport_status_key": "Legenda tonen voor {name}", "ui.viewport_status_minimum_radius": "Minimale straal", "ui.viewport_status_pairs": "{count} paren", @@ -4217,6 +4678,7 @@ "ui.visual_shaded_with_edges": "Gearceerd met randen", "ui.visual_style": "Visuele stijl", "ui.visual_wireframe": "Draadmodel", + "ui.wheel_zoom_speed": "Zoomsnelheid van het muiswiel", "ui.width": "Breedte", "ui.window_system": "Venstersysteem", "ui.window_system_auto": "Automatisch", @@ -4247,7 +4709,9 @@ "update.button": "Bijwerken", "update.button_version": "Bijwerken naar {version}", "update.check_automatically": "Automatisch controleren op updates", + "update.check_for_updates": "Controleren op updates", "update.check_now": "Nu controleren", + "update.check_unavailable": "Controleren op updates is niet beschikbaar voor deze installatie.", "update.checking": "Controleren…", "update.copy": "Kopiëren", "update.dmg_opened": "Het schijfkopie is geopend en geverifieerd: sleep SolidCraft naar Programma's, vervang de oude kopie en start SolidCraft daarna opnieuw.", @@ -4261,13 +4725,18 @@ "update.installed": "SolidCraft {version} is geïnstalleerd. Start opnieuw om het te gebruiken.", "update.installer_started": "Het installatieprogramma start wanneer SolidCraft wordt gesloten.", "update.later": "Herinner me later", + "update.managed": "Update beschikbaar: werk SolidCraft bij in {manager}", "update.no_notes": "Deze release heeft geen notities.", "update.package_hint": "Deze kopie kwam van een {kind}. Download het nieuwe pakket en installeer het daarna met:", "update.prefs_dev": "Dit is een ontwikkelbuild ({version}): er wordt niet op updates gecontroleerd.", "update.prefs_flatpak": "Flatpak werkt deze kopie ({version}) bij: voer {command} uit of gebruik uw softwarecentrum. SolidCraft controleert niet.", "update.prefs_label": "Updates", + "update.prefs_managed": "Dit is SolidCraft {version}, beheerd door {manager}. Een controle vraagt GitHub om de nieuwste release en verzendt verder niets; werk SolidCraft bij in {manager}.", "update.prefs_note": "Dit is SolidCraft {version} ({kind}). Een controle vraagt GitHub om de nieuwste release en verzendt verder niets.", "update.prefs_web": "Laad de pagina opnieuw om de nieuwste web-app te krijgen. Als het netwerk niet beschikbaar is of de update niet op tijd kan worden gedownload, wordt de opgeslagen versie geopend; een download die bezig is, gaat door voor de volgende keer opnieuw laden.", + "update.refresh_checking": "Zoeken naar de nieuwste release…", + "update.refresh_fallback": "Kon niet controleren op een nieuwere release; bijwerken naar {version}, eerder gevonden.", + "update.refresh_selected": "Bijwerken naar {version}.", "update.release_page": "Releasepagina openen", "update.restart": "Nu opnieuw starten", "update.restart_later": "Later", diff --git a/assets/locales/pl.json b/assets/locales/pl.json index bbb533d0b6..95a3f31b93 100644 --- a/assets/locales/pl.json +++ b/assets/locales/pl.json @@ -1,11 +1,15 @@ { + "(active rule)": "(aktywna reguła)", "0 turns it off": "0 wyłącza", "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Otwórz poniżej szkic zgłoszenia na GitHubie, a następnie wklej raport skopiowany do schowka.", "1. Open GitHub draft below. Your title and report will be filled in.": "1. Otwórz poniżej szkic zgłoszenia na GitHubie. Tytuł i raport zostaną wypełnione.", "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Dołącz zrzut ekranu: skopiuj go i wklej albo zapisz i prześlij plik PNG.", "2D": "2D", "3. Click Submit new issue on GitHub to send your report.": "3. Kliknij Submit new issue na GitHubie, aby wysłać raport.", + "A Single hole snaps to the middle of its face and to the centres of its round edges": "Pojedynczy otwór przyciąga się do środka swojej ściany i do środków jej okrągłych krawędzi", "A new configuration, a copy of the active one": "Nowa konfiguracja, kopia aktywnej", + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "Umieszczony pojedynczy otwór ma uchwyt do przeciągania, a ponowne wybranie jego ściany przenosi tam otwór, zamiast go usuwać", + "A short title for the problem (optional)": "Krótki tytuł problemu (opcjonalnie)", "ARRANGE": "UKŁADANIE", "About SolidCraft": "O programie SolidCraft", "Acceleration": "Przyspieszenie", @@ -14,42 +18,70 @@ "Active": "Aktywne", "Active rule": "Aktywna reguła", "Add": "Dodaj", + "Add Column": "Dodaj kolumnę", "Add Key": "Dodaj klucz", "Add Mid Radius": "Dodaj promień środkowy", "Add Row": "Dodaj wiersz", + "Add a tool": "Dodaj narzędzie", + "Add all bodies": "Dodaj wszystkie bryły", + "Add bend": "Dodaj gięcie", "Add condition": "Dodaj warunek", "Add or edit rule": "Dodaj lub edytuj regułę", + "Add to New Folder": "Dodaj do nowego folderu", + "Add visible bodies": "Dodaj widoczne bryły", + "Advanced": "Zaawansowane", "Against": "Względem", "Alignment": "Wyrównanie", + "All Lines": "Wszystkie linie", "All Sketch Points": "Wszystkie punkty szkicu", "All components": "Wszystkie komponenty", "All open designs": "Wszystkie otwarte projekty", "Allowed expressions, separated by semicolons": "Dozwolone wyrażenia, rozdzielone średnikami", "Also deletes": "Usuwa również", "Also place them from the origin (fully constrained)": "Umieść je też względem początku (w pełni związane)", + "Anchor": "Kotwica", "Angle 2": "Kąt 2", + "Angle Display": "Wyświetlanie kąta", "Animate": "Animuj", + "Another bend on the far edge of the last flange": "Kolejne gięcie na dalszej krawędzi ostatniego kołnierza", "App/system information and command settings are included. Review private details before sharing.": "Dołączone są informacje o aplikacji i systemie oraz ustawienia poleceń. Przed udostępnieniem sprawdź, czy nie ma tam prywatnych danych.", "Appearance name": "Nazwa wyglądu", "Applied Radius": "Zastosowany promień", "Apply To": "Zastosuj do", + "Apply library appearance": "Zastosuj wygląd z biblioteki", "Apply to selection": "Zastosuj do zaznaczenia", "Apply your appearance first. Exports assigned appearances and materials created in this design.": "Najpierw zastosuj wygląd. Eksportuje przypisane wyglądy i materiały utworzone w tym projekcie.", "Arc length": "Długość łuku", "Area": "Pole powierzchni", + "As Picked": "Jak wybrano", "As-Built": "W stanie zastanym", "Assign Rule": "Przypisz regułę", "Attachment": "Zaczepienie", "BLEND SURFACE": "POWIERZCHNIA PRZEJŚCIOWA", "BOUNDARY SURFACE": "POWIERZCHNIA Z KONTURU", "BROWSER": "PRZEGLĄDARKA", + "Ball": "Kuliste", + "Bar": "Pręt", + "Base Center": "Środek podstawy", + "Base Height": "Wysokość podstawy", + "Bend": "Gięcie", + "Bend Edges": "Krawędzie gięcia", + "Bend Line": "Linia gięcia", "Bend Line Position": "Położenie linii gięcia", "Bend Position": "Położenie gięcia", "Bend Radius": "Promień gięcia", "Bend Table": "Tabela gięć", "Bends": "Gięcia", "Bodies": "Bryły", + "Bodies (optional)": "Bryły (opcjonalnie)", "Body": "Bryła", + "Body (last if none)": "Bryła (ostatnia, jeśli nie wybrano)", + "Body to split": "Bryła do podziału", + "Body, or faces to take out": "Bryła lub ściany do usunięcia", + "Both": "Obie", + "Bottom Left": "Dół z lewej", + "Bottom Middle": "Dół pośrodku", + "Bottom Right": "Dół z prawej", "Boundary": "Kontur", "Bounding box": "Prostopadłościan otaczający", "CHECK": "SPRAWDZANIE", @@ -61,33 +93,48 @@ "CSV or JSON: updates existing parameters, adds new ones": "CSV lub JSON: aktualizuje istniejące parametry, dodaje nowe", "Capture All Positions": "Zapamiętaj wszystkie położenia", "Capture Pos.": "Zapamiętaj poł.", + "Catch Depth": "Głębokość zaczepu", "Catch Direction": "Kierunek zaczepu", + "Catch Length": "Długość zaczepu", "Cells": "Komórki", "Center X": "Środek X", "Center Y": "Środek Y", "Center of mass": "Środek masy", "Centre to centre": "Od środka do środka", + "Centroid": "Środek ciężkości", "Chamfer Type": "Typ fazy", + "Channel": "Ceownik", "Check collisions": "Sprawdzaj kolizje", "Checked": "Sprawdzono", + "Choose Variable radius or Spline in Type.": "Wybierz w polu Typ opcję Zmienny promień lub Splajn.", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Wybierz nową płaszczyznę. Geometria i wymiary szkicu pozostają niezmienione w jego dwuwymiarowych współrzędnych.", "Choose…": "Wybierz…", + "Circle center": "Środek okręgu", + "Circle or sphere": "Okrąg lub sfera", "Circular": "Kołowy", "Circumference": "Obwód", "Clockwise": "Zgodnie z ruchem wskazówek zegara", "Close draft": "Zamknij szkic zgłoszenia", + "Collapse": "Zwiń", "Collisions": "Kolizje", "Colour": "Kolor", + "Command": "Polecenie", "Comment": "Komentarz", + "Component 1": "Komponent 1", + "Component 2": "Komponent 2", "Component1": "Komponent1", + "Components": "Komponenty", "Config Table": "Tabela konfiguracji", "Configuration": "Konfiguracja", "Configuration rules": "Reguły konfiguracji", + "Constant radius": "Stały promień", "Contact": "Kontakt", "Contact Sets": "Zestawy kontaktów", "Contact sets": "Zestawy kontaktów", "Continue on GitHub": "Kontynuuj na GitHubie", "Continuity": "Ciągłość", + "Control Radius": "Promień kontrolny", + "Copies the definition only. Assign it from Physical Material when ready.": "Kopiuje tylko definicję. Gdy będzie gotowa, przypisz ją przez Materiał fizyczny.", "Copy Error Message": "Kopiuj komunikat błędu", "Copy Warning Message": "Kopiuj komunikat ostrzeżenia", "Copy all licences": "Kopiuj wszystkie licencje", @@ -96,19 +143,33 @@ "Copy screenshot": "Kopiuj zrzut ekranu", "Corner Relief": "Odciążenie naroża", "Corner Relief Shape": "Kształt odciążenia naroża", + "Corner radius": "Promień naroża", "Counterbore depth": "Głębokość pogłębienia walcowego", + "Counterbore Ø": "Ø pogłębienia walcowego", "Countersink angle": "Kąt pogłębienia stożkowego", + "Countersink Ø": "Ø pogłębienia stożkowego", + "Create Hole": "Utwórz otwór", "Create Issue — review draft": "Utwórz zgłoszenie — sprawdź szkic", + "Create Issue…": "Utwórz zgłoszenie…", "Cross curves": "Krzywe poprzeczne", + "Ctrl+Enter continues on GitHub.": "Ctrl+Enter przechodzi dalej na GitHubie.", "Cube": "Sześcian", "Current orthographic view (1:1 mm)": "Bieżący widok ortogonalny (1:1 mm)", "Current perspective view": "Bieżący widok perspektywiczny", + "Current plane": "Bieżąca płaszczyzna", "Curv. Comb": "Grzebień krzyw.", "Curvature (G2)": "Krzywizna (G2)", "Curvature change (G3)": "Zmiana krzywizny (G3)", "Custom": "Niestandardowy", "Customize Toolbar": "Dostosuj pasek narzędzi", + "Customize…": "Dostosuj…", + "Cylindrical": "Walcowe", + "Cylindrical face": "Ściana walcowa", + "Deboss": "Wgłębienie", "Delete": "Usuń", + "Delete Folders and Features…": "Usuń foldery i funkcje…", + "Delete Selected Features…": "Usuń zaznaczone funkcje…", + "Delete Selected Folders": "Usuń zaznaczone foldery", "Delete all": "Usuń wszystko", "Delete rule": "Usuń regułę", "Delete the row": "Usuń wiersz", @@ -119,14 +180,20 @@ "Describe what happened before continuing.": "Zanim przejdziesz dalej, opisz, co się stało.", "Designs you open or save appear here.": "Tutaj pojawiają się otwierane lub zapisywane projekty.", "Determined by To Object. Choose Angle to edit the angular extent.": "Wynika z opcji Do obiektu. Wybierz Kąt, aby edytować zakres kątowy.", + "Diameter": "Średnica", + "Did this assembly open the wrong way up?": "Czy to złożenie otworzyło się odwrócone?", + "Did this part open the wrong way up?": "Czy ta część otworzyła się odwrócona?", "Dimension new shapes": "Wymiaruj nowe kształty", "Direction": "Kierunek", "Discard": "Odrzuć", + "Distance": "Odległość", + "Distance 1": "Odległość 1", "Do not export hidden edges": "Nie eksportuj krawędzi niewidocznych", "Don't ask again": "Nie pytaj ponownie", "Double-click to rename": "Kliknij dwukrotnie, aby zmienić nazwę", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dwukrotne kliknięcie środkowym przyciskiem w widoku: Widok główny (kadruje to, co widoczne), Dopasuj (kadruje to, co widoczne, zachowując orientację) lub nic", "Drag to move the boss across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć występ po modelu. Przyciąga się do punktów szkicu na ścianie.", + "Drag to move the hole; click to remove it": "Przeciągnij, aby przesunąć otwór; kliknij, aby go usunąć", "Drag to move the rest across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć podparcie po modelu. Przyciąga się do punktów szkicu na ścianie.", "Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć zatrzask po modelu. Przyciąga się do punktów szkicu na ścianie.", "Drag to point the catch in another direction.": "Przeciągnij, aby skierować zaczep w inną stronę.", @@ -135,87 +202,149 @@ "Duration": "Czas trwania", "Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Każdy nowy kształt otrzymuje też wymiary od początku szkicu, aż nic nie pozostanie swobodne. Kształt już połączony z początkiem lub z umieszczoną geometrią nie otrzymuje żadnych.", "Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Każdy narysowany kształt otrzymuje wszystkie swoje wymiary w narysowanych rozmiarach (wpisane wartości mają pierwszeństwo). Rozmiar, który szkic już określa, jest pomijany.", + "Edge": "Krawędź", "Edges": "Krawędzie", "Edit": "Edytuj", "Edit Profile Sketch": "Edytuj szkic profilu", + "Edit shortcuts": "Edytuj skróty", "Enable rule": "Włącz regułę", "Enabled": "Włączone", + "End Radius": "Promień końcowy", "End bulge": "Wybrzuszenie końca", "End continuity": "Ciągłość końca", + "Equal Distance": "Równa odległość", + "Expand": "Rozwiń", "Expected result (optional)": "Oczekiwany wynik (opcjonalnie)", "Explode": "Rozstrzel", "Explode Text": "Rozbij tekst", "Export Animation…": "Eksportuj animację…", + "Export Bend Table…": "Eksportuj tabelę gięć…", "Export CSV…": "Eksportuj CSV…", "Export Current View as DXF": "Eksportuj bieżący widok jako DXF", "Export Current View as DXF…": "Eksportuj bieżący widok jako DXF…", "Export DXF": "Eksportuj DXF", "Export DXF…": "Eksportuj DXF…", "Export JSON…": "Eksportuj JSON…", + "Export design library...": "Eksportuj bibliotekę projektu...", "Export hidden edges as dashed lines": "Eksportuj krawędzie niewidoczne jako linie kreskowe", + "Exported by SolidWorks, which usually saves Y up": "Wyeksportowano z SolidWorks, który zwykle zapisuje z osią Y w górę", "Expression": "Wyrażenie", "Extend to meet": "Wydłuż do spotkania", "Extents": "Zakres", "FILES": "PLIKI", "FLAT": "ROZWINIĘCIE", "Face": "Ściana", + "Face Appearance": "Wygląd ściany", "Face attachment lost": "Utracono zaczepienie do ściany", + "Face center": "Środek ściany", "Faces": "Ściany", "Faces / edges / vertices": "Ściany / krawędzie / wierzchołki", + "Faces to split": "Ściany do podziału", + "Faces/Body": "Ściany/bryła", + "Factor must be a positive number": "Współczynnik musi być liczbą dodatnią", "Fastener": "Element złączny", "Favorites": "Ulubione", "Favourite": "Ulubione", "Feature": "Funkcja", "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Pliki z wielu programów CAD mają oś Y skierowaną w górę, więc otwierają się tutaj leżąc na plecach (oś Z w górę).", "Filter": "Filtr", + "Filter…": "Filtr…", "Find in Browser": "Znajdź w przeglądarce", "Finish": "Wykończenie", + "First edge": "Pierwsza krawędź", + "First plane": "Pierwsza płaszczyzna", + "First point": "Pierwszy punkt", + "Fit and G2 corners require Constant radius.": "Dopasowanie i naroża G2 wymagają stałego promienia.", + "Fixed Face": "Ściana stała", + "Flange Gap": "Szczelina kołnierza", + "Flange thickness": "Grubość półki", + "Flanges": "Kołnierze", "Flat Pattern": "Rozwinięcie", "Flat size": "Wymiary rozwinięcia", + "Flat size X": "Wymiar rozwinięcia X", + "Flat size Y": "Wymiar rozwinięcia Y", "Follow camera (Look At sketches)": "Podążaj za kamerą (Patrz na szkice)", "Follow the camera (Look At sketches)": "Podążaj za kamerą (Patrz na szkice)", "Font": "Czcionka", + "Free Move": "Swobodne przesuwanie", + "From": "Od", "From Bodies": "Z brył", + "From Snap": "Od punktu przyciągania", "From edges": "Z krawędzi", "Full Length": "Pełna długość", + "Full thread depth": "Głębokość pełnego gwintu", + "Further Bends": "Kolejne gięcia", + "G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "Naroża G2 wymagają stałego promienia i dopasowania Dokładne. Tworzenie naroży G2 nie jest jeszcze obsługiwane.", "Gap": "Szczelina", "Grid": "Siatka", "HISTORY": "HISTORIA", + "Halo under sketch curves": "Obwódka pod krzywymi szkicu", "Height": "Wysokość", + "Height From": "Wysokość od", + "Hem Gap": "Szczelina zawinięcia", "Hidden edges": "Krawędzie niewidoczne", "History layout": "Układ historii", "History names": "Nazwy w historii", + "Hole (optional)": "Otwór (opcjonalnie)", + "Hole Depth": "Głębokość otworu", + "Hole Diameter": "Średnica otworu", "Holes": "Otwory", "Holes (edges on them)": "Otwory (krawędzie na nich)", "Home view": "Widok główny", "Horiz./Vert.": "Poz./Pion.", + "I-Beam": "Dwuteownik", "IMAGES": "OBRAZY", + "If you are offline or GitHub does not open, use Copy report and keep the text to submit later.": "Jeśli jesteś offline lub GitHub się nie otwiera, użyj Kopiuj raport i zachowaj tekst, aby wysłać go później.", + "Import material into design": "Importuj materiał do projektu", "Import…": "Importuj…", "In This Design": "W tym projekcie", "In place (surfaces)": "W miejscu (powierzchnie)", "In plane": "W płaszczyźnie", + "Included": "Uwzględnione", + "Inner Sharp": "Ostre wewnętrzne", "Inside": "Wewnątrz", + "Instant": "Natychmiast", + "Interface font": "Czcionka interfejsu", "Interferes": "Koliduje", + "Invert": "Odwróć", "Isocurve": "Izokrzywa", "Issue title": "Tytuł zgłoszenia", + "Items": "Elementy", "JOINTS": "ZŁĄCZA", + "Joint 1": "Złącze 1", + "Joint 2": "Złącze 2", "Joint Origin1": "Początek złącza1", + "K Factor": "Współczynnik K", "Keep (points on it)": "Zachowaj (punkty na niej)", "Keep Tools": "Zachowaj narzędzia", "Keep of the other surface": "Zachowaj z drugiej powierzchni", + "Key a joint's value at this step": "Zapisz klucz wartości złącza w tym kroku", + "Keys": "Klucze", + "Knife Edge": "Ostrze", + "L-Angle": "Kątownik", "LIBRARY": "BIBLIOTEKA", "LINKS": "ŁĄCZA", "Later": "Później", "Leave it": "Zostaw", + "Left Middle": "Lewo pośrodku", "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Starsze zaczepienie do ściany. Ponowne powiązanie z certyfikowaną ścianą tworzy dokładne zaczepienie.", "Length": "Długość", "Length units": "Jednostki długości", "Letter Spacing": "Odstęp między literami", "Library": "Biblioteka", "Library file": "Plik biblioteki", + "Library response is missing": "Brak odpowiedzi biblioteki", + "Limits": "Limity", "Line Spacing": "Odstęp między wierszami", + "Line Type": "Rodzaj linii", + "Line on plane": "Linia na płaszczyźnie", + "Lines": "Linie", + "Listing the installed fonts…": "Wyszukiwanie zainstalowanych czcionek…", + "Load library": "Wczytaj bibliotekę", "Local library": "Biblioteka lokalna", "Loop": "Pętla", + "M5 or M5x0.5": "M5 lub M5x0.5", + "M5, M5x0.5, 1/4-20 UNC": "M5, M5x0.5, 1/4-20 UNC", "MANAGE": "ZARZĄDZANIE", "MATERIALS": "MATERIAŁY", "MODEL": "MODEL", @@ -225,23 +354,37 @@ "Mass Center": "Środek masy", "Matched restrictions check the final values.": "Ograniczenia, których warunki są spełnione, sprawdzają wartości końcowe.", "Material": "Materiał", + "Material Outside": "Materiał na zewnątrz", + "Material copied. Existing body assignments are unchanged.": "Skopiowano materiał. Istniejące przypisania do brył pozostają bez zmian.", "Max": "Maks.", + "Max Thickness": "Maks. grubość", + "Maximum": "Maksimum", + "Mesh body": "Bryła siatkowa", "Mid Radius": "Promień środkowy", "Middle double-click": "Dwukrotne kliknięcie środkowym", "Min": "Min.", + "Min Draft": "Min. pochylenie", + "Min Thickness": "Min. grubość", + "Minimum": "Minimum", + "Mirror Plane": "Płaszczyzna odbicia", "Mitre": "Ukos", "Model Parameters": "Parametry modelu", "Modeled": "Wymodelowane", "More toolbar panels": "Więcej paneli paska narzędzi", "Motion": "Ruch", + "Mouse gestures": "Gesty myszy", + "Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "Gesty myszy działają tylko w trybie myszy SolidWorks (kółko obraca widok, Ctrl + kółko przesuwa).", "Move / Copy": "Przesuń / Kopiuj", "Move Type": "Typ przesunięcia", "Move down": "Przenieś w dół", "Move earlier": "Przesuń wcześniej", + "Move holes by clicking or dragging": "Przesuwaj otwory kliknięciem lub przeciąganiem", "Move later": "Przesuń później", "Move up": "Przenieś w górę", "Name": "Nazwa", + "Name on import": "Nazwa przy imporcie", "Nearest": "Najbliższy", + "Neutral plane": "Płaszczyzna neutralna", "New": "Nowy", "New Comp.": "Nowy komp.", "New Sketch": "Nowy szkic", @@ -250,68 +393,110 @@ "No crash reports.": "Brak raportów o awariach.", "No matching appearance presets.": "Brak pasujących ustawień wyglądu.", "No matching appearances": "Brak pasujących wyglądów", + "No matching physical materials.": "Brak pasujących materiałów fizycznych.", "No visible bodies or sketches to export.": "Brak widocznych brył lub szkiców do wyeksportowania.", + "Nominal Radius": "Promień nominalny", "Nominal diameter": "Średnica nominalna", "None": "Brak", "Not available yet": "Jeszcze niedostępne", + "Not saved yet": "Jeszcze nie zapisano", "Notch": "Wycięcie", "OUTPUT": "WYJŚCIE", + "Of": "Obiekt", + "On your computer": "Na tym komputerze", "Opacity": "Nieprzezroczystość", + "Open GitHub": "Otwórz GitHub", "Open GitHub draft": "Otwórz szkic zgłoszenia na GitHubie", + "Open GitHub issue form": "Otwórz formularz zgłoszenia na GitHubie", + "Open bodies (no volume)": "Bryły otwarte (bez objętości)", + "Open library...": "Otwórz bibliotekę...", "Open local libraries in the desktop app. You can export this design's library here.": "Otwórz biblioteki lokalne w aplikacji komputerowej. Tutaj możesz wyeksportować bibliotekę tego projektu.", + "Open surface — no volume": "Powierzchnia otwarta — bez objętości", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Otwartoźródłowy parametryczny CAD 3D w języku Rust. Licencja MIT OR Apache-2.0.", "Opens your browser and copies the report text for you to paste into GitHub.": "Otwiera przeglądarkę internetową i kopiuje treść raportu, którą wkleisz na GitHubie.", + "Opens your browser and copies the report text. Review and submit your report on GitHub.": "Otwiera przeglądarkę internetową i kopiuje treść raportu. Sprawdź raport i wyślij go na GitHubie.", "Opens your browser. Review and submit your report on GitHub.": "Otwiera przeglądarkę internetową. Sprawdź raport i wyślij go na GitHubie.", "Operator": "Operator", "Orbit (right drag)": "Obracanie widoku (przeciąganie prawym przyciskiem)", "Other side": "Druga strona", "Other surface (mutual trim)": "Druga powierzchnia (wzajemne przycięcie)", + "Other way": "W drugą stronę", + "Outer Sharp": "Ostre zewnętrzne", + "Outer radius": "Promień zewnętrzny", "Outside": "Na zewnątrz", "Overall length": "Długość całkowita", "Param. Graph": "Wykres param.", "Parameter": "Parametr", + "Part length must be greater than zero and at most 2000 mm": "Długość części musi być większa od zera i nie większa niż 2000 mm", "Parts": "Części", "Pass through": "Przechodzi przez", "Path": "Ścieżka", "Pattern": "Szyk", "Per Edge": "Dla każdej krawędzi", + "Physical material definitions": "Definicje materiałów fizycznych", + "Pick a part size": "Wybierz rozmiar części", "Pick the tool bodies": "Wybierz bryły narzędziowe", "Picked": "Wybrane", "Picked point": "Wybrany punkt", + "Pin-slot": "Sworzeń w rowku", "Pitch": "Skok", + "Pivot": "Punkt obrotu", "Place at the origin instead": "Zamiast tego umieść w początku układu", + "Planar": "Płaskie", "Plastic Rules": "Reguły tworzyw", + "Play the joint's motion in the view": "Odtwórz ruch złącza w widoku", "Playback": "Odtwarzanie", "Plot": "Wykres", + "Point on line": "Punkt na linii", + "Point on plane": "Punkt na płaszczyźnie", "Position": "Położenie", "Position (0 to 1)": "Położenie (od 0 do 1)", "Position (G0)": "Położenie (G0)", "Position along the edge, 0 at the start end, 1 at the other": "Położenie wzdłuż krawędzi: 0 na jej początku, 1 na drugim końcu", "Preferences › Design turns the offer back on": "Preferencje › Projekt ponownie włącza tę propozycję", + "Preview": "Podgląd", "Preview Row": "Podgląd wiersza", "Preview leaves the current design unchanged.": "Podgląd nie zmienia bieżącego projektu.", + "Profile / Edges": "Profil / krawędzie", + "Profiles or curves": "Profile lub krzywe", "Profiles/Curves": "Profile/krzywe", "Quantity": "Liczba", + "Quantity must be at least 2": "Liczba musi wynosić co najmniej 2", "RECENT": "OSTATNIE", "REFERENCE": "ODNIESIENIE", "RULED SURFACE": "POWIERZCHNIA PROSTOKREŚLNA", "RULES": "REGUŁY", + "Radius": "Promień", "Radius Fit": "Dopasowanie promienia", "Ratio": "Stosunek", + "Read-only list: shortcuts can't be changed here.": "Lista tylko do odczytu: tutaj nie można zmieniać skrótów.", "Reattach selected face": "Ponownie zaczep wybraną ścianę", "Recover": "Odzyskaj", "Recover unsaved design?": "Odzyskać niezapisany projekt?", "Rectangular": "Prostokątny", + "Rectangular Tube": "Profil prostokątny zamknięty", "Reference face": "Ściana odniesienia", + "Relief Depth": "Głębokość odciążenia", + "Relief Width": "Szerokość odciążenia", "Remove Tools": "Usuń narzędzia", + "Remove condition": "Usuń warunek", + "Remove from Folder": "Usuń z folderu", "Remove from list": "Usuń z listy", "Remove override": "Usuń nadpisanie", "Remove private design details or paths before sharing.": "Przed udostępnieniem usuń prywatne szczegóły projektu i ścieżki.", "Remove screenshot": "Usuń zrzut ekranu", "Remove the column": "Usuń kolumnę", "Remove the track": "Usuń ścieżkę", + "Remove this bend": "Usuń to gięcie", "Remove this point": "Usuń ten punkt", + "Rename Feature": "Zmień nazwę funkcji", + "Rename Folder": "Zmień nazwę folderu", + "Render Stage": "Scenografia renderowania", + "Reopen designs": "Otwieraj ponownie projekty", + "Reopen the designs that were open when SolidCraft was last closed": "Otwieraj ponownie projekty, które były otwarte przy ostatnim zamknięciu SolidCraft", + "Replace existing library file": "Zastąp istniejący plik biblioteki", "Report a bug — review a screenshot and open a GitHub draft": "Zgłoś błąd — sprawdź zrzut ekranu i otwórz szkic zgłoszenia na GitHubie", + "Report copied. Keep this text to submit on GitHub when you are online.": "Skopiowano raport. Zachowaj ten tekst, aby wysłać go na GitHubie, gdy będziesz online.", "Report text copied. Paste it into GitHub and submit your report there.": "Skopiowano treść raportu. Wklej ją na GitHubie i tam wyślij raport.", "Reports are public. A GitHub account is required.": "Raporty są publiczne. Wymagane jest konto GitHub.", "Reset Toolbar": "Przywróć pasek narzędzi", @@ -319,8 +504,10 @@ "Restore View": "Przywróć widok", "Restore outer boundary": "Przywróć kontur zewnętrzny", "Restore the inherited component or physical material appearance": "Przywróć wygląd odziedziczony z komponentu lub materiału fizycznego", + "Restore this studio's tools": "Przywróć narzędzia tego studia", "Result": "Wynik", "Retake screenshot": "Ponów zrzut ekranu", + "Reveal Height": "Wysokość odsłonięcia", "Reverse": "Odwróć", "Reverse Direction": "Odwróć kierunek", "Revert": "Przywróć", @@ -329,14 +516,28 @@ "Revert Position": "Przywróć położenie", "Review the report and screenshot before opening GitHub.": "Przed otwarciem GitHuba sprawdź raport i zrzut ekranu.", "Review your report on GitHub and click Submit new issue to publish it.": "Sprawdź raport na GitHubie i kliknij Submit new issue, aby go opublikować.", + "Revolute": "Obrotowe", + "Rib Length": "Długość żebra", + "Rib Offset": "Odsunięcie żebra", + "Rib Thickness": "Grubość żebra", + "Ribs": "Żebra", + "Right Middle": "Prawo pośrodku", + "Rigid": "Sztywne", "Rim Edge": "Krawędź obrzeża", + "Rim face": "Ściana obrzeża", + "Ring of tools on the right button": "Pierścień narzędzi pod prawym przyciskiem", + "Roll Back Here": "Cofnij tutaj", "Roll History Marker Here": "Przesuń tutaj znacznik historii", + "Root radius": "Promień u nasady", "Round": "Okrągłe", + "Round Bar": "Pręt okrągły", + "Round Tube": "Rura okrągła", "Rule": "Reguła", "Rule name": "Nazwa reguły", "Rules run from top to bottom. Matched drives change effective values.": "Reguły są wykonywane od góry do dołu. Reguły sterujące, których warunki są spełnione, zmieniają wartości efektywne.", "SECTION": "PRZEKRÓJ", "SURFACE QUALITY": "JAKOŚĆ POWIERZCHNI", + "Same way": "W tę samą stronę", "Save Rule": "Zapisz regułę", "Save View": "Zapisz widok", "Save screenshot…": "Zapisz zrzut ekranu…", @@ -348,17 +549,36 @@ "Search": "Szukaj", "Search commands": "Szukaj poleceń", "Search this library": "Przeszukaj tę bibliotekę", + "Second edge": "Druga krawędź", + "Second plane": "Druga płaszczyzna", + "Second point": "Drugi punkt", "Section": "Przekrój", + "Section (optional)": "Przekrój (opcjonalnie)", "Section position": "Położenie przekroju", + "Sections (one sketch each)": "Przekroje (każdy w osobnym szkicu)", + "Select Other": "Wybierz inny", "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).": "Wybierz inny: przytrzymaj nieruchomo lewy przycisk na modelu (lub prawy przycisk › Wybierz inny…), aby wyświetlić wszystko pod kursorem; kliknij ponownie w tym samym miejscu, aby zaznaczyć kolejny obiekt. Dwukrotne kliknięcie środkowym przyciskiem przechodzi do widoku głównego (Preferencje › Nawigacja).", + "Select Same Look": "Zaznacz o tym samym wyglądzie", "Select bodies (a face picks its body)": "Zaznacz bryły (ściana wybiera swoją bryłę)", "Select bodies or faces instead": "Zamiast tego zaznacz bryły lub ściany", + "Select edges": "Zaznacz krawędzie", + "Select points of the sketch being edited": "Zaznacz punkty edytowanego szkicu", + "Select the centre the pattern turns around first": "Najpierw zaznacz środek, wokół którego obraca się szyk", + "Select the curves to offset first": "Najpierw zaznacz krzywe do odsunięcia", + "Select the mirror line": "Zaznacz linię odbicia", + "Select the sketch curves or points to mirror first": "Najpierw zaznacz krzywe lub punkty szkicu do odbicia", + "Select the sketch curves or points to move first": "Najpierw zaznacz krzywe lub punkty szkicu do przesunięcia", + "Select the sketch curves or points to pattern first": "Najpierw zaznacz krzywe lub punkty szkicu do szyku", + "Select the sketch curves or points to scale first": "Najpierw zaznacz krzywe lub punkty szkicu do skalowania", + "Select the sketch curves or points to transform first": "Najpierw zaznacz krzywe lub punkty szkicu do przekształcenia", "Selection filter": "Filtr zaznaczania", "Semantic faces share their appearance across instances.": "Ściany semantyczne współdzielą wygląd między wystąpieniami.", "Send your report on GitHub": "Wyślij raport na GitHubie", + "Shaded": "Cieniowany", "Shape": "Kształt", "Sheet Rules": "Reguły blach", "Show Agent Cursor": "Pokaż kursor agenta", + "Show ghost": "Pokaż widmo", "Show in folder": "Pokaż w folderze", "Show the pointer an AI agent moves (#35)": "Pokaż wskaźnik poruszany przez agenta AI (#35)", "Show tool at cursor": "Pokaż narzędzie przy kursorze", @@ -370,64 +590,108 @@ "Sketch geometry and profiles": "Geometria i profile szkicu", "Sketch plane has no body-face attachment.": "Płaszczyzna szkicu nie ma zaczepienia do ściany bryły.", "Sketch points": "Punkty szkicu", + "Slider": "Przesuwne", "Slot width": "Szerokość rowka", + "Snap to face and edge centres": "Przyciągaj do środków ścian i krawędzi", + "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft nie może wysyłać raportów bezpośrednio. Wyślij je na GitHubie; aplikacja nie potrzebuje do tego tokenu.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft zamknął się, zanim te projekty zostały zapisane. Można otworzyć ich ostatni autozapis.", + "Spacing 2 must be a non-zero number": "Odstęp 2 musi być liczbą różną od zera", + "Spacing must be a non-zero number": "Odstęp musi być liczbą różną od zera", "Specialise": "Uszczegółów", "Speed": "Prędkość", + "Splitting tool": "Narzędzie podziału", "Square": "Kwadrat", "Stand it up": "Postaw", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Postaw: obrót o ćwierć obrotu wokół osi X, jako jedna funkcja Przesuń, którą można cofnąć.", + "Stand this assembly up?": "Postawić to złożenie?", "Stand this part up?": "Postawić tę część?", "Start angle": "Kąt początkowy", "Start bulge": "Wybrzuszenie początku", "Start continuity": "Ciągłość początku", + "Stationary Side": "Strona nieruchoma", "Step": "Krok", "Steps": "Kroki", "Steps to reproduce (optional)": "Kroki do odtworzenia (opcjonalnie)", "Stitch the kept pieces": "Zszyj zachowane części", "Stitch to both bodies": "Zszyj z obiema bryłami", + "Studio": "Studio", "Study": "Badanie", "Style": "Styl", "Summary": "Podsumowanie", + "Suppress": "Wygaś", + "Suppress Selected Folders": "Wygaś zaznaczone foldery", "Suppressed": "Wygaszone", "Surface": "Powierzchnia", + "Surface, or faces to turn over": "Powierzchnia lub ściany do odwrócenia", + "Surfaces": "Powierzchnie", "Swap Ends": "Zamień końce", "TRIM": "PRZYCINANIE", + "Tangent": "Styczność", "Tangent (G1)": "Styczność (G1)", "Tangent Chain": "Łańcuch styczny", + "Tangent chain": "Łańcuch styczny", + "Tangent to Edge": "Styczna do krawędzi", "Tap drill diameter": "Średnica wiertła pod gwint", + "Target": "Cel", + "Target Body": "Bryła docelowa", + "Target Face (optional)": "Ściana docelowa (opcjonalnie)", "Tear": "Rozdarcie", "Technical details": "Szczegóły techniczne", "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Przerywany szkic to zapisana płaszczyzna pomocnicza. Wybierz nową ścianę, aby naprawić zaczepienie.", + "The design changed. Reopen Change Part Size for the component.": "Projekt się zmienił. Otwórz ponownie Zmień rozmiar części dla tego komponentu.", + "The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Kierunek musi być osią lub prostą krzywą szkicu leżącą w płaszczyźnie szkicu", + "The mirror line cannot also be mirrored": "Linia odbicia nie może być jednocześnie odbijana", + "The mirror line must be a straight curve of this sketch, not an origin axis": "Linia odbicia musi być prostą krzywą tego szkicu, a nie osią początku układu", "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Brak zapisanego wystąpienia autora. Przed ponownym powiązaniem wybierz prawidłowe położenie autora.", "Then": "Wtedy", "These features will be deleted:": "Te funkcje zostaną usunięte:", "These features will fail:": "Te funkcje się nie powiodą:", "Thickness Outside": "Grubość na zewnątrz", + "Thickness Variation": "Zmienność grubości", "Thin": "Cienkościenne", "Thin Extrude": "Wyciągnięcie cienkościenne", + "Third plane": "Trzecia płaszczyzna", + "Third point": "Trzeci punkt", "This child is shared. Deleting it removes it from every copy of its parent.": "Ten element podrzędny jest współdzielony. Usunięcie go usuwa go z każdej kopii elementu nadrzędnego.", "This child is shared. Position changes apply to every copy of its parent.": "Ten element podrzędny jest współdzielony. Zmiany położenia dotyczą każdej kopii elementu nadrzędnego.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Ten komponent jest w podzłożeniu umieszczonym więcej niż raz: wyrównanie przesuwa go w każdym umiejscowieniu.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ta ściana nie zapewnia trwałego zaczepienia. Wybierz inną ścianę lub jawnie użyj stałej płaszczyzny.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ten obraz nie jest jeszcze dołączony na GitHubie. Skopiuj go lub zapisz, a potem dołącz na GitHubie.", + "Thread depth": "Głębokość gwintu", + "Thread diameter": "Średnica gwintu", + "Through point": "Przez punkt", + "To": "Do", "To Offset": "Do odsunięcia", + "To Snap": "Do punktu przyciągania", "To edges": "Do krawędzi", + "Toe radius": "Promień na końcu półki", "Tolerance": "Tolerancja", "Too close": "Zbyt blisko", + "Tool Bodies": "Bryły narzędziowe", + "Tools": "Narzędzia", + "Tools, clockwise from the top": "Narzędzia, zgodnie z ruchem wskazówek zegara od góry", + "Top Left": "Góra z lewej", + "Top Middle": "Góra pośrodku", + "Top Right": "Góra z prawej", "Trimming tool (plane, face or curves)": "Narzędzie przycinające (płaszczyzna, ściana lub krzywe)", "Turn off section analysis before exporting the view.": "Przed eksportem widoku wyłącz analizę przekroju.", "Type": "Typ", + "Type a distance or an angle to move by": "Wpisz odległość lub kąt przesunięcia", "UNTRIM": "COFNIJ PRZYCIĘCIE", "Unit": "Jednostka", + "Unsuppress": "Przywróć wygaszone", + "Unsuppress Selected Folders": "Przywróć wygaszone zaznaczone foldery", "Updating the preview": "Aktualizowanie podglądu", "Use active author placement": "Użyj aktywnego położenia autora", "Use fixed plane": "Użyj stałej płaszczyzny", "Use saved fixed plane": "Użyj zapisanej stałej płaszczyzny", "Use selected face as fixed plane": "Użyj wybranej ściany jako stałej płaszczyzny", + "Use the SolidWorks mouse mode": "Użyj trybu myszy SolidWorks", "User Parameters": "Parametry użytkownika", "VIEW": "WIDOK", "VIEWS": "WIDOKI", "Value": "Wartość", + "Variable Radius": "Zmienny promień", "Velocity": "Prędkość", "Vertices": "Wierzchołki", "View navigator": "Nawigator widoku", @@ -435,21 +699,48 @@ "Wall": "Ścianka", "Wall Location": "Położenie ścianki", "Wall Thickness": "Grubość ścianki", + "Web thickness": "Grubość środnika", "What did you do just before the problem? A short list is enough": "Co robiłeś tuż przed wystąpieniem problemu? Wystarczy krótka lista", "What did you expect to see?": "Czego się spodziewałeś?", "What happened?": "Co się stało?", "When": "Gdy", "Width": "Szerokość", "Will fail": "Nie powiedzie się", + "With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "Gdy gesty są włączone, prawy przycisk nie obraca już widoku: przytrzymaj go i porusz myszą, aby otworzyć pierścień, a potem najedź na strefę narzędzia. Zwykłe kliknięcie prawym nadal otwiera menu.", + "X": "X", + "X Angle": "Kąt X", "X angle": "Kąt X", "X distance": "Odległość X", + "Y": "Y", + "Y Angle": "Kąt Y", "Y angle": "Kąt Y", "Y distance": "Odległość Y", + "Z": "Z", + "Z Angle": "Kąt Z", + "Z Distance": "Odległość Z", "Z angle": "Kąt Z", "Z distance": "Odległość Z", "Zebra": "Zebra", + "`joint` must be an id or a name": "`joint` musi być identyfikatorem lub nazwą", + "`params` must be an object": "`params` musi być obiektem", + "`values` must be a list": "`values` musi być listą", + "a chamfer needs a corner of exactly two lines": "faza wymaga naroża z dokładnie dwóch linii", + "a file operation is in progress": "trwa operacja na pliku", + "a picked curve is no longer in a sketch": "wybrana krzywa nie należy już do szkicu", + "a picked curve's sketch has not been solved": "szkic wybranej krzywej nie został rozwiązany", + "a picked edge has no points": "wybrana krawędź nie ma punktów", + "a picked edge is gone": "wybrana krawędź już nie istnieje", + "a picked edge's body is gone": "bryła wybranej krawędzi już nie istnieje", + "add a key for a joint first": "najpierw dodaj klucz dla złącza", + "ai.connected_toast": "SolidCraft jest teraz dostępny w: {clients}. Uruchom je ponownie, aby go używać (Preferencje > Asystent).", + "ai.prefs.allow_control": "Pozwól agentom AI się łączyć (kanał sterowania / MCP)", + "ai.prefs.allow_control_note": "Otwiera kanał sterowania tylko na tym komputerze (127.0.0.1, port 27878 lub następny wolny). Łączący się program musi też podać tajny token, który może odczytać tylko Twoje konto użytkownika, tworzony na nowo przy każdym uruchomieniu. Działa od następnego uruchomienia SolidCraft. Wyłączone: nic nie nasłuchuje, chyba że uruchomisz SolidCraft z --control.", "ai.prefs.availability": "Sterowanie asystentem", "ai.prefs.background_commands": "Uruchamiaj polecenia agenta w tle", + "ai.prefs.connect_apps": "Udostępnij SolidCraft aplikacjom AI na tym komputerze", + "ai.prefs.connect_apps_note": "Przy uruchomieniu SolidCraft dodaje go jako serwer MCP do aplikacji AI znalezionych na tym komputerze (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), aby agent mógł go używać bez konfiguracji. Dodawany jest tylko wpis solidcraft lub odświeżana jego ścieżka; zmieniony przez Ciebie wpis pozostaje nietknięty, a plik jest najpierw kopiowany. Wyłączone: dodane wpisy są usuwane. Pomoc > Połącz asystentów AI… robi to ręcznie.", + "ai.prefs.connect_at_start": "Łącz ostatnio używanego agenta przy uruchomieniu SolidCraft", + "ai.prefs.connect_at_start_note": "Przy każdym uruchomieniu SolidCraft uruchamia w tle ostatnio połączonego agenta (lub domyślnego), aby był gotowy do rozmowy. Nie działa, gdy SolidCraft jest uruchomiony z --control. Jeśli nie uda się połączyć, Asystent pokaże Połączenie nieudane i przycisk Ponów.", "ai.prefs.follow_camera": "Obracaj kamerę do szkiców", "ai.prefs.follow_camera_off": "Automatyczne podążanie kamery jest wyłączone.", "ai.prefs.follow_camera_on": "Kamera obraca się przodem do szkiców tworzonych przez agenta.", @@ -457,6 +748,8 @@ "ai.prefs.make_default": "Używaj domyślnie", "ai.prefs.no_agents": "Brak agentów. Dodaj agenta lub odśwież, aby wyszukać zainstalowane agenty.", "ai.prefs.no_saved_approvals": "Brak zapisanych zgód.", + "ai.prefs.open_at_start": "Otwieraj panel Asystenta przy uruchomieniu SolidCraft", + "ai.prefs.open_at_start_note": "Pokazuje panel Asystenta (lub jego osobne okno) przy każdym uruchomieniu SolidCraft. Nie działa, gdy SolidCraft jest uruchomiony z --control.", "ai.prefs.override_all_warning": "Ten agent może zapisywać, nadpisywać, eksportować i zastępować projekt bez pytania.", "ai.prefs.permission_all": "Zezwalaj na wszystko", "ai.prefs.permission_all_summary": "Dostępne narzędzia SolidCraft działają bez pytania.", @@ -480,6 +773,7 @@ "appearance.roughness": "Chropowatość", "appearance.surface_response": "Reakcja powierzchni", "at the face": "przy ścianie", + "bom.group_children": "Podczęści w wierszu ich części", "browser.body_name": "Bryła{number}", "browser.calibrate_title": "Kalibruj: {name}", "browser.capture_many": "Przesunięto komponenty ({count}), ale ich położenia nie zostały zapamiętane.", @@ -487,9 +781,12 @@ "browser.capture_one": "Przesunięto komponent, ale jego położenie nie zostało zapamiętane.", "browser.component_name": "Komponent{number}", "browser.edit_canvas_title": "Edytuj kanwę: {name}", + "browser.edit_point_cloud_title": "Edytuj chmurę punktów: {name}", "browser.move_title": "Przesuń: {name}", "browser.parameters": "Parametry ({count})", "browser.precision": "Dokładność: {precision}", + "browser.sketch_attachment_lost": "{name} (utracono zaczepienie)", + "browser.sketch_editing": "{name} (edytowany)", "browser.sketch_name": "Szkic{number}", "browser.units": "Jednostki: {units}", "cam.flat": "Płaska ściana", @@ -499,15 +796,36 @@ "cam.roller": "Rolka", "cam.roller_pick": "Rolka (opcjonalnie)", "cam.tip_pick": "Końcówka (opcjonalnie)", + "camera.shot_pitch": "Pochylenie", + "camera.shot_roll": "Przechylenie", + "camera.shot_yaw": "Odchylenie", + "canvas.decal_place_hint": "Kliknij ścianę, aby umieścić naklejkę", + "canvas.place_hint": "Kliknij płaszczyznę lub ścianę, aby umieścić płótno", + "choose a profile": "wybierz profil", + "choose the cam's and the follower's joints": "wybierz złącza krzywki i popychacza", + "choose the joints to couple": "wybierz złącza do sprzężenia", + "click a plane or planar face": "kliknij płaszczyznę lub płaską ścianę", + "click sketch curves": "kliknij krzywe szkicu", "cmd.appearance_assign": "Wygląd", + "cmd.appearance_chain": "Łańcuch wyglądów", + "cmd.appearance_copy": "Kopiuj wygląd", + "cmd.appearance_favorite": "Ulubiony wygląd", "cmd.appearance_library": "Biblioteka wyglądów", "cmd.appearance_list": "Lista wyglądów", + "cmd.appearance_paint": "Maluj wyglądem", + "cmd.appearance_paste": "Wklej wygląd", + "cmd.appearance_pick_up": "Pobierz wygląd", + "cmd.appearance_replace": "Zastąp wygląd", "cmd.assembly_bom": "Zestawienie materiałowe", + "cmd.body.print_face": "Powierzchnia druku", "cmd.body_rename": "Zmień nazwę bryły", + "cmd.browser_collapse_all": "Zwiń wszystko", + "cmd.browser_expand_all": "Rozwiń wszystko", "cmd.browser_group": "Nowa grupa", "cmd.browser_move": "Przenieś elementy przeglądarki", "cmd.browser_order": "Uporządkuj elementy przeglądarki", "cmd.browser_rename_group": "Zmień nazwę grupy", + "cmd.browser_reparent": "Przenieś do komponentu", "cmd.browser_ungroup": "Rozgrupuj", "cmd.browser_visibility": "Pokaż/ukryj", "cmd.canvas_calibrate": "Kalibruj płótno", @@ -519,8 +837,14 @@ "cmd.component_activate": "Aktywuj komponent", "cmd.component_break_link": "Zerwij łącze", "cmd.component_capture_position": "Zapamiętaj położenie", + "cmd.component_copies": "Znajdź identyczne kopie", + "cmd.component_copy_bodies": "Wklej bryły", "cmd.component_create": "Nowy komponent", + "cmd.component_cycle_color": "Zmień kolor komponentu", "cmd.component_delete": "Usuń komponent", + "cmd.component_edit_in_place": "Edytuj w miejscu", + "cmd.component_extract": "Wyodrębnij do pliku", + "cmd.component_finish_edit": "Zakończ edycję", "cmd.component_from_bodies": "Utwórz komponenty z brył", "cmd.component_insert_design": "Wstaw projekt", "cmd.component_links": "Lista łączy", @@ -528,11 +852,14 @@ "cmd.component_metadata": "Właściwości części", "cmd.component_metadata_set": "Zmień właściwości części", "cmd.component_move_bodies": "Przenieś bryły do komponentu", + "cmd.component_move_features": "Przenieś funkcje do komponentu", "cmd.component_move_sketches": "Przenieś szkice do komponentu", + "cmd.component_paste_from_design": "Wklej z innego projektu", "cmd.component_paste_new": "Wklej jako nowy", "cmd.component_properties": "Właściwości fizyczne komponentu", "cmd.component_relink": "Połącz ponownie ze źródłem", "cmd.component_rename": "Zmień nazwę komponentu", + "cmd.component_replace_with_link": "Zastąp łączem", "cmd.component_revert_position": "Przywróć położenie", "cmd.component_unlock": "Odblokuj zawartość", "cmd.component_update_links": "Aktualizuj łącza", @@ -582,6 +909,7 @@ "cmd.document_revision": "Wersja projektu", "cmd.document_units": "Zmień jednostki projektu", "cmd.edit_checkpoint": "Punkt kontrolny", + "cmd.edit_repeat": "Powtórz ostatnie polecenie", "cmd.edit_restore_checkpoint": "Przywróć punkt kontrolny", "cmd.engine_commands": "Lista poleceń", "cmd.engine_describe": "Opisz polecenie", @@ -596,6 +924,7 @@ "cmd.file_insert_step": "Wstaw STEP", "cmd.file_open_in_easyprint": "Otwórz w EasyPrint", "cmd.file_save_mesh": "Zapisz jako siatkę", + "cmd.file_send_to_slicer": "Wyślij do slicera", "cmd.file_slicers": "Zainstalowane slicery", "cmd.frame_member": "Element ramy", "cmd.frame_profile_insert": "Wstaw profil", @@ -604,6 +933,8 @@ "cmd.inspect_accessibility": "Analiza dostępności", "cmd.inspect_center_of_mass": "Środek masy", "cmd.inspect_clearance": "Prześwit", + "cmd.inspect_colored_components": "Włącz kolory komponentów", + "cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów", "cmd.inspect_compare": "Porównaj bryły", "cmd.inspect_continuity": "Analiza ciągłości", "cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny", @@ -613,6 +944,7 @@ "cmd.inspect_interference": "Kolizja", "cmd.inspect_isocurve": "Analiza izokrzywych", "cmd.inspect_minimum_radius": "Analiza promienia minimalnego", + "cmd.inspect_parameter_sweep": "Przegląd wartości parametru", "cmd.inspect_silhouette": "Sylwetka", "cmd.inspect_zebra": "Analiza zebry", "cmd.joint_as_built": "Złącze w stanie zastanym", @@ -657,11 +989,15 @@ "cmd.motion_plot": "Wykres badania ruchu", "cmd.motion_study": "Badanie ruchu", "cmd.motion_trace": "Ślad ruchu", + "cmd.motion_trace_edit": "Edytuj ślad ruchu", "cmd.motion_trace_keep": "Zachowaj ślad ruchu", + "cmd.occurrence_align": "Wyrównaj wystąpienie", "cmd.occurrence_copy": "Wklej (wystąpienie)", + "cmd.occurrence_drag": "Przeciągnij wystąpienie", "cmd.occurrence_ground": "Unieruchom", "cmd.occurrence_move": "Przesuń wystąpienie", "cmd.occurrence_properties": "Właściwości fizyczne wystąpienia", + "cmd.occurrence_reparent": "Przenieś do komponentu", "cmd.parameters_add": "Dodaj parametr użytkownika", "cmd.parameters_change": "Zmień parametry", "cmd.parameters_comment": "Komentarz parametru", @@ -686,17 +1022,73 @@ "cmd.plastic_rest": "Podparcie", "cmd.plastic_rules": "Lista reguł tworzyw", "cmd.plastic_snap_fit": "Zatrzask", + "cmd.pointcloud_delete": "Usuń chmurę punktów", + "cmd.pointcloud_edit": "Edytuj chmurę punktów", + "cmd.pointcloud_insert": "Wstaw chmurę punktów", + "cmd.pointcloud_list": "Chmury punktów", + "cmd.render_aging_describe": "Warstwowe starzenie powierzchni", "cmd.render_backends": "Silniki renderowania", + "cmd.render_batch": "Renderuj wszystkie ujęcia", + "cmd.render_camera_fit": "Kadruj obiekt renderowania", + "cmd.render_camera_get": "Sprawdź kamerę renderowania", "cmd.render_cancel": "Anuluj renderowanie", + "cmd.render_compare": "Porównaj obrazy", + "cmd.render_effects_describe": "Efekty obiektywu i kreatywne", "cmd.render_export": "Eksportuj obraz renderowania", + "cmd.render_hdr_inspect": "Sprawdź źródło HDR", + "cmd.render_library_appearance_apply": "Zastosuj nieprzezroczysty wygląd z biblioteki", + "cmd.render_library_describe": "Sprawdź zasób biblioteki renderowania", + "cmd.render_library_environment_apply": "Zastosuj wbudowane otoczenie HDR", + "cmd.render_library_list": "Przeglądaj bibliotekę renderowania", + "cmd.render_library_physical_prepare": "Przygotuj odniesienie fizyczne", + "cmd.render_library_preview": "Sprawdź oryginalną mapę powierzchni", + "cmd.render_library_studio_apply": "Zastosuj autorskie oświetlenie studyjne", + "cmd.render_library_studio_prepare": "Przygotuj autorskie oświetlenie studyjne", + "cmd.render_library_surface_bind": "Zapisz powiązanie powierzchni", + "cmd.render_library_surface_prepare": "Przygotuj autorskie mapy powierzchni", + "cmd.render_library_surface_unbind": "Usuń powiązanie powierzchni", + "cmd.render_library_targets": "Znajdź cele biblioteki renderowania", + "cmd.render_lights_add": "Dodaj światło", + "cmd.render_lights_duplicate": "Powiel światło renderowania", + "cmd.render_lights_list": "Lista świateł renderowania", + "cmd.render_lights_mute": "Wycisz światło", + "cmd.render_lights_remove": "Usuń światło renderowania", + "cmd.render_lights_set": "Ustaw światło renderowania", + "cmd.render_lights_solo": "Tylko to światło", + "cmd.render_optics_bind": "Zapisz powiązanie optyczne", + "cmd.render_optics_unbind": "Usuń powiązanie optyczne", + "cmd.render_output_size": "Ustaw rozmiar obrazu renderowania", "cmd.render_preflight": "Sprawdź zlecenie renderowania", + "cmd.render_preflight_copy": "Kopiuj raport kontroli renderowania", + "cmd.render_preset_apply": "Zastosuj natywne studio", + "cmd.render_preset_resolve": "Przejrzyj natywne studio", + "cmd.render_quality_prepare": "Przejrzyj stały budżet próbkowania", + "cmd.render_result_close": "Zamknij obraz renderowania", + "cmd.render_result_copy": "Kopiuj obraz renderowania", + "cmd.render_result_pin": "Zachowaj wyświetlany wynik renderowania", + "cmd.render_result_regrade": "Ponownie skoryguj kolory oryginału liniowego", + "cmd.render_result_view": "Pokaż obraz renderowania", "cmd.render_scene": "Sprawdź scenę renderowania", "cmd.render_settings_apply": "Zastosuj kamerę renderowania", + "cmd.render_settings_capture_hdr": "Zapisz ustawienia renderowania z HDR", "cmd.render_settings_get": "Ustawienia renderowania", "cmd.render_settings_set": "Zapisz ustawienia renderowania", + "cmd.render_shot_duplicate": "Powiel ujęcie renderowania", + "cmd.render_shot_remove": "Usuń ujęcie renderowania", + "cmd.render_shot_rename": "Zmień nazwę ujęcia renderowania", + "cmd.render_stage_backdrop": "Zapisz fizyczne tło renderowania", + "cmd.render_stage_clear": "Usuń scenografię renderowania", + "cmd.render_stage_ground": "Zapisz podłoże renderowania", "cmd.render_start": "Renderuj obraz", "cmd.render_status": "Postęp renderowania", "cmd.select_clear": "Wyczyść zaznaczenie", + "cmd.settings_profile_apply": "Zastosuj profil ustawień", + "cmd.settings_profile_delete": "Usuń profil ustawień", + "cmd.settings_profile_export": "Eksportuj profil ustawień", + "cmd.settings_profile_import": "Importuj profil ustawień", + "cmd.settings_profile_list": "Profile ustawień", + "cmd.settings_profile_rename": "Zmień nazwę profilu ustawień", + "cmd.settings_profile_save": "Zapisz profil ustawień", "cmd.sheet_convert": "Konwertuj na blachę", "cmd.sheet_export_bend_table": "Eksportuj tabelę gięć", "cmd.sheet_export_dxf": "Eksportuj rozwinięcie jako DXF", @@ -705,10 +1097,22 @@ "cmd.sheet_fold": "Zagięcie", "cmd.sheet_hem": "Zawinięcie", "cmd.sheet_manage_rules": "Reguły blach", + "cmd.sheet_path_flange": "Kołnierz wzdłuż ścieżki", "cmd.sheet_perforate": "Perforacja", "cmd.sheet_refold": "Zegnij ponownie", "cmd.sheet_rules": "Lista reguł blach", "cmd.sheet_unfold": "Rozegnij", + "cmd.sketch.construction_line": "Prosta konstrukcyjna", + "cmd.sketch.construction_line.angle": "Prosta konstrukcyjna pod kątem", + "cmd.sketch.construction_line.bisector": "Dwusieczna kąta", + "cmd.sketch.construction_line.horizontal": "Pozioma prosta konstrukcyjna", + "cmd.sketch.construction_line.parallel": "Równoległa prosta konstrukcyjna", + "cmd.sketch.construction_line.perpendicular": "Prostopadła prosta konstrukcyjna", + "cmd.sketch.construction_line.tangent_at": "Styczna w punkcie krzywej", + "cmd.sketch.construction_line.tangent_external": "Styczna przez punkt zewnętrzny", + "cmd.sketch.construction_line.tangent_two": "Wspólna styczna", + "cmd.sketch.construction_line.vertical": "Pionowa prosta konstrukcyjna", + "cmd.sketch_arc3d": "Łuk 3D", "cmd.sketch_arc_center_point": "Łuk ze środka", "cmd.sketch_arc_tangent": "Łuk styczny", "cmd.sketch_arc_three_point": "Łuk przez 3 punkty", @@ -716,6 +1120,7 @@ "cmd.sketch_auto_constrain_from_datum": "Wiązania automatyczne od bazy", "cmd.sketch_auto_project": "Automatyczne rzutowanie krawędzi odniesienia", "cmd.sketch_blend_curve": "Krzywa przejściowa", + "cmd.sketch_cancel": "Anuluj szkic", "cmd.sketch_chain": "Łańcuch", "cmd.sketch_chamfer_distance_angle": "Faza odległość i kąt", "cmd.sketch_chamfer_equal_distance": "Faza równa odległość", @@ -736,8 +1141,11 @@ "cmd.sketch_drag": "Przeciągnij geometrię szkicu", "cmd.sketch_drag_end": "Zakończ przeciąganie szkicu", "cmd.sketch_draw_dimensioned": "Rysuj zwymiarowaną geometrię szkicu", + "cmd.sketch_drawing_bounds": "Rozmiar rysunku", "cmd.sketch_edit": "Edytuj szkic", "cmd.sketch_edit_text": "Edytuj tekst", + "cmd.sketch_equation_curve": "Krzywa z równania", + "cmd.sketch_equation_curve_edit": "Edytuj krzywą z równania", "cmd.sketch_export_dxf": "Zapisz jako DXF", "cmd.sketch_finish_auto_constrain": "Zakończ z wiązaniami automatycznymi", "cmd.sketch_fit_curves_to_section": "Dopasuj krzywe do przekroju siatki", @@ -780,9 +1188,12 @@ "cmd.sketch_spline_control_point": "Splajn z punktów kontrolnych", "cmd.sketch_spline_control_point_5": "Splajn z punktów kontrolnych (stopień 5)", "cmd.sketch_spline_fit_point": "Splajn przez punkty", + "cmd.sketch_spline_insert_point": "Dodaj punkt splajnu", "cmd.sketch_spline_reverse": "Odwróć kierunek splajnu", "cmd.sketch_spun_profile": "Profil obrotowy", + "cmd.sketch_toggle_diameter": "Przełącz promień / średnicę", "cmd.sketch_toggle_driven": "Przełącz wymiar sterowany", + "cmd.sketch_transform": "Przekształć", "cmd.sketch_trim_path": "Przytnij wzdłuż ścieżki", "cmd.solid_align": "Wyrównaj", "cmd.solid_boundary_fill": "Wypełnienie konturem", @@ -820,28 +1231,58 @@ "cmd.timeline_export_script": "Eksportuj skrypt poleceń", "cmd.timeline_inspect": "Sprawdź funkcję", "cmd.timeline_paste": "Wklej funkcje", + "cmd.timeline_play": "Odtwórz historię", "cmd.timeline_redefine": "Edytuj funkcję", "cmd.timeline_rename": "Zmień nazwę", "cmd.timeline_reorder": "Zmień kolejność funkcji", "cmd.timeline_roll_to": "Przesuń znacznik historii", "cmd.timeline_rollback": "Przesuń znacznik osi czasu", + "cmd.timeline_stop": "Zatrzymaj historię", "cmd.timeline_suppress": "Wygaś funkcję", + "cmd.view.render_browser": "Przeglądarka renderowania", + "cmd.view.render_dock": "Przenieś dok renderowania", + "cmd.view.render_focus": "Widok skupienia na renderowaniu", + "cmd.view.status_items": "Elementy stanu widoku", "cmd.view_ambient_occlusion": "Okluzja otoczenia", + "cmd.view_angle_readout": "Kąty widoku", "cmd.view_apply": "Przywróć widok nazwany", + "cmd.view_back": "Widok z tyłu", + "cmd.view_bottom": "Widok z dołu", "cmd.view_delete": "Usuń widok nazwany", + "cmd.view_fit": "Dopasuj", + "cmd.view_front": "Widok z przodu", "cmd.view_graphics_preset": "Ustawienie graficzne", + "cmd.view_grid": "Siatka", + "cmd.view_ground_shadow": "Cień na podłożu", + "cmd.view_home": "Widok główny", + "cmd.view_isometric": "Widok izometryczny", + "cmd.view_left": "Widok z lewej", "cmd.view_list": "Lista widoków nazwanych", "cmd.view_navigate": "Nawiguj widokiem", + "cmd.view_navigation_bar": "Pasek nawigacji", + "cmd.view_next_tab": "Następna karta", "cmd.view_normal_to": "Prostopadle do zaznaczenia", + "cmd.view_previous_tab": "Poprzednia karta", "cmd.view_rename": "Zmień nazwę widoku nazwanego", + "cmd.view_render_frame": "Linie kadru renderowania", + "cmd.view_right": "Widok z prawej", "cmd.view_save": "Nowy widok nazwany", + "cmd.view_shots_strip": "Pasek ujęć", + "cmd.view_spin": "Ciągłe obracanie", + "cmd.view_tool": "Narzędzie nawigacji", + "cmd.view_top": "Widok z góry", + "cmd.view_visual_style": "Styl wizualny", "color.copy": "Kliknij, aby skopiować wartości koloru", "color.edit": "Kliknij, aby edytować kolor", "color.gamma": "Wartości koloru w przestrzeni gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Wpisz #rrggbb lub #rrggbbaa; sześć cyfr zachowuje nieprzezroczystość", + "color.hex_hint": "#rrggbb", "color.hue": "Odcień", "color.linear": "Wartości koloru w przestrzeni liniowej 0-1", "color.saturation_value": "Nasycenie i wartość", "color.selected": "Wybrany kolor", + "config.column_filter_empty": "Brak pasujących kolumn", "contact.components_count": "Liczba komponentów: {count}", "contact.enabled_label": "włączone", "coupling.gear_1": "Koło zębate 1", @@ -875,6 +1316,7 @@ "crash.report": "Zgłoś…", "crash.unclean_explanation": "Wykryto przy późniejszym uruchomieniu; dokładny czas zamknięcia nie został zapisany. Nieprawidłowe zamknięcie może też wynikać z wyłączenia systemu lub wymuszonego zatrzymania.", "delete.and_more": "… i jeszcze {count}", + "diagnostic.empty_boolean_component": "w komponencie '{component}' nie ma bryły do wycięcia lub przycięcia; narzędzie może nachodzić na '{body}' w komponencie '{other}' — aktywuj ten komponent lub wybierz tę bryłę jako cel", "dialog.bodies_count": "Bryły: {count}", "dialog.clearance_invalid": "luz musi być długością równą 0 lub większą", "dialog.compute": "Oblicz", @@ -885,13 +1327,26 @@ "dialog.dimension_insert_hint": "Kliknij dwukrotnie inny wymiar sterujący, aby wstawić jego nazwę", "dialog.dimension_name": "Wymiar: {name}", "dialog.edit_title": "EDYCJA: {title}", + "dialog.no_shown_body": "Brak widocznej bryły do zmiany: pokaż jakąś lub wybierz Nowa bryła", "dialog.select": "↖ Zaznacz", "dialog.selected_count": "↖ Zaznaczono: {count}", "dialog.selection_guidance": "Wybierz pole, aby je zmienić. Kliknij zaznaczone obiekty, aby je usunąć; Ctrl/Cmd+kliknięcie również je przełącza. Sprawdź podgląd i naciśnij OK.", "dialog.selection_step": "{step} z {total}: zaznacz {input}", "dialog.step": "Krok", + "document.changed_on_disk": "{name} zmienił się na dysku", + "document.changed_on_disk_detail": "{name} został zmieniony przez inny program lub komputer od chwili otwarcia lub ostatniego zapisu. Nadpisz zastępuje te zmiany Twoimi; Zapisz kopię zachowuje obie wersje.", + "document.copy_name": "{name} — kopia", + "document.open_anyway": "Otwórz mimo to", + "document.open_elsewhere": "{name} jest już otwarty", + "document.open_elsewhere_detail": "{name} jest otwarty w SolidCraft {app} na {who}, otwarty {when}. Jeśli oboje zapiszecie, ktoś straci swoją pracę. Otwórz go tylko do odczytu, aby go obejrzeć bez nadpisywania.", + "document.open_read_only": "Otwórz tylko do odczytu", + "document.opened_read_only": "Otwarto tylko do odczytu: Zapisz poprosi o nowy plik", + "document.overwrite": "Nadpisz", + "document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik", "document.save_changes": "Zapisać zmiany w {name}?", + "document.save_copy": "Zapisz kopię…", "document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.", + "drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.", "drive.stopped_by_contact": "Zatrzymano przy {value}: {a} przeszedłby przez {b}", "drive.stopped_by_loop": "Zatrzymano przy {value}: pętla przez {joint} nie może się bardziej zamknąć", "drive.unconnected": "Nic się nie porusza: {part} nie jest połączony z niczym unieruchomionym", @@ -907,9 +1362,13 @@ "dxf.progress.write": "Zapisywanie DXF (5/5)", "dxf.tolerance": "Tolerancja krzywych", "dxf.tolerance_help": "Tylko widoczne bryły i pokazane szkice. Krzywe są przybliżane odcinkami. Mniejsza tolerancja daje dokładniejsze krzywe, ale zajmuje więcej czasu i pamięci.", + "each rail's curves must be in one sketch": "krzywe każdej prowadnicy muszą leżeć w jednym szkicu", + "feature computation ended without a result": "obliczanie funkcji zakończyło się bez wyniku", + "file.open.drawing_filter": "Projekty, pliki CAD, siatki i rysunki", "file_load.built": "Dotąd zbudowano: {built} z {total} elementów.", "file_load.converting": "Konwertowanie bryły {done} z {total}", "file_load.inserting": "Wstawianie geometrii", + "file_load.link_item": "{name} ({done} z {total})", "file_load.opening": "Otwieranie projektu", "file_load.preparing_thumbnail": "Przygotowywanie miniatury...", "file_load.preparing_view": "Przygotowywanie widoku...", @@ -919,24 +1378,77 @@ "file_load.rebuilding": "Przebudowa elementu {done} z {total}", "file_load.recovering": "Odzyskiwanie projektu", "file_load.stopping": "Oczekiwanie na zatrzymanie poprzedniej operacji...", + "file_load.updating_links": "Aktualizowanie łączy", "file_load.waiting": "Oczekiwanie na operację na pliku", "fits the face, e.g. M8": "pasuje do ściany, np. M8", + "from the profile": "z profilu", "from the rule": "z reguły", "help.apache": "Licencja Apache 2.0", + "help.appearance.assign": "Nadaje zaznaczonym bryłom, ścianom lub komponentom kolor albo wygląd materiału, bez zmiany ich masy. Służy do odróżniania części lub podglądu wykończenia.", "help.attributions": "Uznanie autorstwa (ATTRIBUTION.md)", "help.build": "Wersja {version} · commit {commit} · zbudowano {date}", + "help.component.finish_edit": "Zapisuje część edytowaną w miejscu, wraca do jej złożenia i aktualizuje je. Użyj, gdy skończysz zmieniać połączoną część.", + "help.connect_note": "Uruchom ponownie każdego asystenta, aby wczytał serwer SolidCraft. Przed zmianą każdy plik zapisano jako kopię .bak.", + "help.connect_title": "Połącz asystentów AI", + "help.face.delete": "Usuwa zaznaczone ściany i pozwala sąsiednim ścianom zamknąć lukę albo pozostawia ją otwartą jako powierzchnię. Służy do usunięcia przeszkadzającego zaokrąglenia, otworu lub występu.", "help.font_details": "Interfejs używa wbudowanych czcionek egui (Ubuntu Light i Hack na licencjach Ubuntu Font Licence oraz MIT/Bitstream Vera; Noto Emoji i czcionka ikon emoji na licencjach OFL i MIT). Tekst szkicu używa Liberation Sans (SIL OFL 1.1). Opcjonalny pakiet czcionek The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), nie jest dołączany do kompilacji SolidCraft.", "help.fonts": "Czcionki", "help.licences": "Licencje", + "help.material.assign": "Ustala, z czego są wykonane zaznaczone bryły, np. ze stali, aluminium lub ABS. Dzięki temu masa i ciężar odpowiadają rzeczywistej części.", "help.mit": "Licencja MIT", "help.notice": "NOTICE", + "help.occurrence.move": "Przesuwa część złożenia tylko w zakresie swobody dozwolonym przez jej złącza. Zaznacz ścianę części, a potem przeciągnij dostępną strzałkę lub łuk.", + "help.parameters.change": "Otwiera tabelę nazwanych wartości i wyrażeń sterujących wymiarami projektu. Służy do zmiany rozmiaru w jednym miejscu, za którą podąża każda funkcja, która go używa.", + "help.pointcloud.insert": "Wstawia skan PLY, XYZ lub PTS jako chmurę punktów obok modelu. Służy jako odniesienie przy budowaniu modelu z danych pomiarowych.", + "help.sketch.equation_curve": "Rysuje krzywą ze wzorów: x(t) i y(t) albo y = f(x), w zadanym zakresie. Wzory mogą używać parametrów, a krzywa za nimi podąża: służy do sinusoidy, spirali lub ewolwenty zęba koła.", + "help.solid.align": "Przesuwa zaznaczone bryły tak, aby punkt, ściana lub płaszczyzna na nich spotkały punkt, ścianę lub płaszczyznę w innym miejscu. Służy do dosunięcia części do innej bez mierzenia odsunięcia.", "help.solid.chamfer": "Ścina zaznaczone krawędzie płaską fazą o zadanej odległości. Służy do łamania ostrych krawędzi lub ułatwienia wprowadzenia kołka czy śruby.", + "help.solid.combine": "Łączy bryły, wycina je z bryły docelowej lub zachowuje tylko ich wspólną objętość, i może zachować bryły narzędziowe. Służy do scalania części, wycinania jednego kształtu drugim lub znajdowania miejsca, gdzie dwie bryły nachodzą na siebie.", + "help.solid.draft": "Pochyla zaznaczone ściany o zadany kąt mierzony od płaszczyzny neutralnej. Służy do części formowanych lub odlewanych, aby dały się wyjąć z formy.", "help.solid.extrude": "Wyciąga profil szkicu w 3D na zadaną odległość, dodając lub usuwając materiał. Służy do zamiany płaskiego obrysu w blok, występ lub kieszeń.", "help.solid.fillet": "Zaokrągla zaznaczone krawędzie gładkim łukiem o zadanym promieniu. Służy do złagodzenia ostrych krawędzi dla wytrzymałości, wygody lub wyglądu.", "help.solid.hole": "Wierci okrągły otwór o zadanej średnicy w wybranym punkcie ściany. Służy do otworów przelotowych pod śruby, kołki i innych prostych okrągłych otworów.", + "help.solid.loft": "Tworzy gładką bryłę przechodzącą przez dwa lub więcej profili szkicu albo od profilu do punktu. Służy do przejść, lejków, uchwytów i każdego kształtu, którego przekrój zmienia się na długości.", + "help.solid.mirror": "Tworzy lustrzaną kopię zaznaczonych brył lub funkcji względem płaszczyzny i może połączyć ją z oryginałem. Służy do części symetrycznych: modelujesz połowę, a dostajesz całość.", + "help.solid.move": "Przesuwa lub obraca zaznaczone bryły albo ich kopię o zadaną odległość i kąt. Służy do zmiany położenia bryły lub utworzenia drugiej w innym miejscu.", + "help.solid.offset_face": "Przesuwa zaznaczone ściany do wewnątrz lub na zewnątrz o zadaną odległość, a otaczające ściany za nimi podążają. Służy do pogrubienia ścianki, pogłębienia kieszeni lub podwyższenia występu bez edycji szkicu.", + "help.solid.pattern.circular": "Powtarza zaznaczone obiekty wokół osi z zadaną liczbą i kątem całkowitym. Służy do okręgów otworów pod śruby, szprych, żeberek i zębów.", + "help.solid.pattern.rectangular": "Powtarza zaznaczone obiekty w jednym lub dwóch kierunkach z zadaną liczbą i odstępem. Służy do rzędów i siatek otworów, żeber, kołków lub nóżek.", + "help.solid.press_pull": "Dodaje lub usuwa materiał, gdy przeciągasz ścianę, albo zaokrągla krawędzie, gdy je przeciągasz. Służy do szybkich zmian bezpośrednio na modelu.", + "help.solid.remove": "Usuwa zaznaczone bryły z projektu od tego miejsca na osi czasu; wcześniejsze kroki je zachowują. Służy do pozbycia się bryły pomocniczej, np. niepotrzebnego już narzędzia tnącego.", + "help.solid.replace_face": "Przenosi zaznaczone płaskie ściany na płaszczyznę lub równoległą ścianę, a bryła rośnie lub maleje, aby do niej dojść. Służy do zrównania ściany z inną częścią lub płaszczyzną konstrukcyjną.", "help.solid.revolve": "Obraca profil szkicu wokół osi, tworząc okrągłą bryłę. Służy do wałków, gałek, misek i wszystkich części okrągłych względem osi.", + "help.solid.scale": "Powiększa lub zmniejsza zaznaczone bryły o współczynnik, jednakowo we wszystkich kierunkach albo osobno dla każdej osi. Służy do zmiany rozmiaru zaimportowanego kształtu lub uwzględnienia skurczu.", "help.solid.shell": "Wydrąża bryłę, pozostawiając ścianki o zadanej grubości, i może otworzyć wybrane ściany. Służy do pudełek, pokryw i innych części cienkościennych.", + "help.solid.split_body": "Dzieli bryłę na osobne bryły płaszczyzną, ścianą innej bryły lub linią szkicu. Służy do tworzenia części drukowanych, obrabianych lub barwionych osobno.", + "help.solid.split_face": "Dzieli zaznaczone ściany wzdłuż płaszczyzny, ściany innej bryły lub krzywych szkicu, bez cięcia bryły. Służy do nadania części ściany własnego pochylenia, koloru lub odsunięcia.", + "help.solid.sweep": "Przeciąga profil szkicu wzdłuż ścieżki, tworząc bryłę. Służy do rur, prowadnic, uchwytów i każdej części o stałym przekroju biegnącej wzdłuż krzywej.", "help.third_party": "Licencje stron trzecich", + "help.timeline.compute_all": "Przelicza od początku każdą funkcję na osi czasu. Służy do odświeżenia modelu i sprawdzenia, które funkcje się nie powiodły.", + "help.timeline.delete": "Usuwa zaznaczone funkcje z osi czasu; usunięcie szkicu usuwa też funkcje na nim zbudowane. Służy do usunięcia niepotrzebnego już kroku.", + "help.timeline.play": "Odtwarza pozostałe funkcje jedna po drugiej, z półsekundową przerwą po każdym przeliczeniu. Uruchom to polecenie ponownie lub naciśnij Escape, aby zatrzymać; Przejdź do końca przeskakuje od razu.", + "history.sketch_under_constrained": "Niedostatecznie związany (stopnie swobody: {count})", + "history.sketch_under_constrained_one": "Niedostatecznie związany (stopnie swobody: {count})", + "hole_presets.confirm_delete": "Potwierdź usunięcie", + "hole_presets.delete": "Usuń", + "hole_presets.delete_prompt": "Usunąć ten własny szablon? Istniejące otwory pozostaną bez zmian.", + "hole_presets.full": "Biblioteka własnych szablonów zawiera już 64 szablony.", + "hole_presets.invalid_name": "Wpisz krótką nazwę bez podziałów wierszy i znaków sterujących.", + "hole_presets.invalid_parameters": "Uzupełnij parametry otworu poprawnymi wyrażeniami rozsądnej długości.", + "hole_presets.missing": "Wybrany szablon nie jest już dostępny.", + "hole_presets.name": "Nazwa szablonu", + "hole_presets.name_exists": "Szablon o tej nazwie już istnieje. Użyj innej nazwy.", + "hole_presets.name_hint": "Unikalna nazwa", + "hole_presets.overwrite": "Zapisz zmiany w wybranym szablonie", + "hole_presets.preset": "Szablon", + "hole_presets.recall": "Wczytaj", + "hole_presets.rename": "Zmień nazwę", + "hole_presets.save": "Zapisz nowy", + "hole_presets.scope": "Zapisywany w Twoim profilu dla wszystkich projektów.", + "hole_presets.select": "Wybierz szablon", + "hole_presets.title": "Własne szablony", + "hole_presets.unsupported_options": "Ten szablon używa opcji gwintu, których ta wersja nie potrafi wczytać.", + "hole_presets.unsupported_overwrite": "Ten szablon zawiera opcje, których nie można bezpiecznie nadpisać.", "home.back": "Wróć do {name}", "home.crashes": "Raporty awarii", "home.design_workspace": "Projektowanie", @@ -1001,6 +1513,9 @@ "install.steps_intro": "Safari instaluje aplikacje internetowe z własnych menu:", "install.title": "Zainstaluj SolidCraft", "issue.body_label": "Treść zgłoszenia", + "issue.note_changed_while_waiting": "Ustawienia lub model zmieniły się w trakcie oczekiwania na podgląd. Zrób ponownie zrzut ekranu, aby zaktualizować szczegóły techniczne dla bieżącego widoku.", + "issue.note_details_kept": "Zrzut ekranu zrobiono lokalnie. Zachowano edytowane przez Ciebie szczegóły techniczne: sprawdź, czy nadal pasują do bieżącego widoku.", + "issue.note_details_updated": "Zaktualizowano szczegóły techniczne do bieżącego widoku. Zrzut ekranu zrobiono lokalnie. Sprawdź go przed dołączeniem na GitHubie.", "issue.note_image_copied": "Wklej obraz do szkicu na GitHubie lub zapisz plik PNG, jeśli kopiowanie obrazu jest niedostępne.", "issue.note_opened": "Przejrzyj szkic na GitHubie i dołącz zrzut ekranu. Przycisk Submit new issue na GitHubie publikuje zgłoszenie.", "issue.note_opened_copied": "Skopiowano raport. Wklej go do szkicu na GitHubie, dołącz zrzut ekranu, a następnie kliknij Submit new issue na GitHubie.", @@ -1012,9 +1527,28 @@ "joint.from_bodies_hint": "Każda wybrana bryła trafia do nowego, własnego komponentu i pozostaje na swoim miejscu.", "joint.one_body": "Oba wybory leżą na {body}. Drugi wybierz na innej części.", "joint.one_component": "Te części są bryłami jednego komponentu: {bodies}. Złącze porusza jeden komponent względem drugiego, więc każda część potrzebuje własnego komponentu.", + "joint.rigid_group_one_component": "Te bryły ({count}) są w jednym komponencie (lub w samym projekcie): najpierw użyj {command}.", + "joint.rigid_group_select_components": "Zaznacz dwa lub więcej komponentów.", + "joint_move.blocked_hint": "Sprawdź typ złącza (np. Sztywne), unieruchomienie, limity złącza i wartości sterowane parametrami.", + "joint_move.blocked_title": "Nie można przesunąć tej części", + "joint_move.coarse": "Krok obrotu przy osi (°)", + "joint_move.distance": "Odległość od początku", + "joint_move.done": "Gotowe", + "joint_move.fine": "Krok obrotu z dala od osi (°)", + "joint_move.fixed": "Ta część nie ma ruchomej osi złącza.", + "joint_move.help": "Przesuń — przeciągnij strzałkę lub pierścień. Esc anuluje bieżące przeciąganie.", + "joint_move.legacy_hint": "Wyłącz, aby używać poprzedniego panelu Przesuń.", + "joint_move.precision": "Wyciągnij poza przerywaną linię, aby obracać precyzyjnie.", + "joint_move.rotation": "Obrót od początku", + "joint_move.select": "Zaznacz ścianę lub jedną część złożenia, a potem wybierz Przesuń.", + "joint_move.settings": "Przesuwanie w złożeniu", + "joint_move.step": "Krok", + "joint_move.use_handles": "Używaj uchwytów ruchu złączy do przesuwania w złożeniu", "keymap.command_id": "ID polecenia", "keymap.empty": "Brak pasujących poleceń", "keymap.use_anyway": "Mimo to użyj tutaj {key}", + "label.move_to_left_side": "Przenieś na lewą stronę", + "label.move_to_right_side": "Przenieś na prawą stronę", "manage.appearance_hint": "Wybierz wygląd dla zaznaczenia lub przeciągnij próbkę na bryłę albo ścianę.", "manage.assign_hint": "Wybierz bryły i ich regułę projektowania części z tworzyw sztucznych.", "manage.config_hint": "Edytuj wiersze, kolumny i reguły konfiguracji, a następnie aktywuj wiersz lub wyświetl jego podgląd.", @@ -1022,6 +1556,51 @@ "manage.material_hint": "Wybierz bryły i ich materiał fizyczny.", "manage.parameters_hint": "Edytuj wyrażenia i jednostki. Zmiany są stosowane po naciśnięciu Enter lub opuszczeniu pola.", "manage.rules_hint": "Wybierz regułę do edycji lub utwórz nową. Potwierdź, aby zapisać regułę.", + "material.editor.add_property": "Dodaj właściwość", + "material.editor.add_source": "Dodaj źródło", + "material.editor.assigned_density": "Przypisana gęstość", + "material.editor.axes": "Osie materiału", + "material.editor.axes_numeric": "1 / 2 / 3", + "material.editor.axes_wood": "L / R / T", + "material.editor.copy_name": "Kopia {name}", + "material.editor.density_range": "Przypisana gęstość musi być większa od 0 i nie większa niż 1000 g/cm³.", + "material.editor.designation": "Oznaczenie", + "material.editor.duplicate": "Powiel", + "material.editor.engineering_card": "Karta właściwości inżynierskich", + "material.editor.family": "Rodzina materiałów", + "material.editor.glass": "Szkło", + "material.editor.illustrative": "Poglądowe", + "material.editor.invalid_property": "Nieobsługiwany typ właściwości.", + "material.editor.isotropic": "Izotropowy", + "material.editor.measurement": "Pomiar", + "material.editor.metal": "Metal", + "material.editor.moisture": "Wilgotność, w przeliczeniu na suchą masę", + "material.editor.name_required": "Wpisz nazwę o długości od 1 do 128 znaków.", + "material.editor.no_draft": "Nie otwarto żadnego szkicu materiału.", + "material.editor.number_required": "Wpisz skończoną wartość liczbową.", + "material.editor.orthotropic": "Ortotropowy", + "material.editor.other": "Inne", + "material.editor.polymer": "Polimer", + "material.editor.preserved_appearance": "Zachowany wygląd", + "material.editor.public_reference": "Publiczne źródło", + "material.editor.reference": "Źródło", + "material.editor.save_or_discard": "Zapisz lub odrzuć szkic materiału przed przypisaniem materiału.", + "material.editor.saved": "Zapisano materiał. Przypisania do brył pozostają bez zmian.", + "material.editor.scope": "Zapisz przechowuje ten materiał bez przypisywania go do brył. Puste właściwości i warunki pozostają nieznane. Gęstość z karty nie zmienia przypisanej gęstości ani masy.", + "material.editor.source_id": "Identyfikator źródła", + "material.editor.source_kind": "Typ źródła", + "material.editor.source_title": "Tytuł źródła", + "material.editor.sources": "Źródła", + "material.editor.stale": "Ten materiał zmienił się, gdy szkic był otwarty. Odrzuć go i otwórz ponownie przed zapisaniem.", + "material.editor.state": "Stan materiału", + "material.editor.strain_rate": "Prędkość odkształcenia", + "material.editor.symmetry": "Symetria sprężystości", + "material.editor.temperature": "Temperatura", + "material.editor.unknown_card": "Właściwości inżynierskie są nieznane. Zapisanie może zachować tylko przypisaną gęstość i istniejący wygląd.", + "material.editor.user_supplied": "Podane przez użytkownika", + "material.editor.wood": "Drewno", + "material.selection.choose": "Wybierz materiał", + "material.selection.mixed": "Różne materiały", "measure.at": "W punkcie", "measure.item": "Element {number}", "messages.ago_h": "{n} godz. temu", @@ -1046,44 +1625,213 @@ "motion.to_end": "Na koniec", "motion.to_start": "Na początek", "motion.track_name": "Ścieżka {number}", + "nav.alias.help": "Shift+Alt+przeciąganie lewym obraca widok, Shift+Alt+środkowym przesuwa, Shift+Alt+prawym powiększa; przeciąganie środkowym także przesuwa, a prawym obraca", + "nav.blender.help": "Przeciąganie środkowym obraca widok, Shift+środkowy przesuwa, Ctrl+środkowy powiększa; przeciąganie prawym nie rusza kamery", "nav.free_orbit.help": "Mysz i mysz 3D obracają widok przez oba bieguny i zachowują przechylenie. Przyciąganie do osi z Shift jest wyłączone.", "nav.free_orbit.label": "Swobodne obracanie", + "nav.free_orbit.snap_help": "Mysz i mysz 3D obracają widok przez oba bieguny i zachowują przechylenie. Przytrzymaj Shift podczas obracania, aby przyciągnąć kierunek widoku i przechylenie.", + "nav.free_orbit_upright.help": "Przeciąganie myszą w bok obraca model wokół pionowej osi świata; przeciąganie w górę i w dół przechyla go nad wierzchołkiem bez ograniczeń.", + "nav.free_orbit_upright.label": "Obracaj wokół osi pionowej", + "nav.fusion.help": "Przeciąganie środkowym przesuwa, Shift+środkowy obraca widok, przeciąganie prawym obraca widok", + "nav.hfss.help": "Alt+przeciąganie lewym przesuwa, Shift+Alt+lewy powiększa, Ctrl+przeciąganie lewym na pustym miejscu obraca widok", + "nav.inventor.help": "Przeciąganie środkowym przesuwa, Shift+środkowy obraca widok, Ctrl+środkowy powiększa, przeciąganie prawym obraca widok", + "nav.maya.help": "Alt+przeciąganie lewym obraca widok, Alt+środkowy przesuwa, Alt+prawy powiększa; przeciąganie środkowym także przesuwa", + "nav.openscad.help": "Przeciąganie lewym na pustym miejscu obraca widok, Shift+przeciąganie lewym zaznacza prostokątem, przeciąganie geometrii szkicu ją edytuje; przeciąganie prawym lub środkowym przesuwa, Shift+prawy lub Shift+środkowy powiększa", + "nav.perspective.sketch": "Edycja szkicu używa widoku ortogonalnego. Zakończ szkic, aby przełączyć na perspektywę.", + "nav.right_drag_orbits.help": "Wyłączone: przeciąganie prawym nie rusza kamery, a prawy przycisk tylko otwiera menu kontekstowe. Wybranie przypisania myszy SolidWorks wyłącza tę opcję.", + "nav.right_drag_orbits.label": "Przeciąganie prawym obraca widok", + "nav.solidworks.help": "Przeciąganie środkowym obraca widok, Ctrl+środkowy przesuwa, Shift+środkowy powiększa, przeciąganie prawym obraca widok", + "nav.tinkercad.help": "Przeciąganie prawym obraca widok, przeciąganie środkowym lub Shift+prawym przesuwa", "nav.touchpad.help": "Przytrzymaj Shift i poruszaj wskaźnikiem, aby przesuwać; przytrzymaj Alt/Option i poruszaj nim, aby obracać widok. Przycisk myszy nie jest potrzebny. Przewijanie w pionie powiększa.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Obracanie widoku (przytrzymaj Alt/Option i poruszaj; bez przycisku myszy)", "nav.touchpad.pan": "Przesuwanie (przytrzymaj Shift i poruszaj; bez przycisku myszy)", "nav.touchpad.scroll": "Touchpad powiększa przewijaniem w pionie. Ustawienie przesuwania dwoma palcami jest zachowane dla innych ustawień wstępnych.", "nav.touchpad.zoom": "Powiększanie (przewijanie w pionie lub szczypanie)", + "navbar.agent_chat": "Panel czatu Asystenta", + "navbar.agent_cursor": "Pokaż kursor agenta", + "navbar.ambient_occlusion": "Okluzja otoczenia", + "navbar.assistant": "Asystent", + "navbar.custom.add_slot": "Dodaj {slot}", + "navbar.custom.always": "Zawsze widoczny", + "navbar.custom.available": "{available} · przeciągnij na pasek lub naciśnij Dodaj", + "navbar.custom.buttons": "Przyciski", + "navbar.custom.compact": "Kompaktowy", + "navbar.custom.count": "{count} z 16 miejsc", + "navbar.custom.customise": "Dostosuj pasek nawigacji…", + "navbar.custom.design": "Projekt", + "navbar.custom.duplicate": "To miejsce jest już na pasku.", + "navbar.custom.edge": "Krawędź", + "navbar.custom.edge_bottom": "Dół", + "navbar.custom.edge_top": "Góra", + "navbar.custom.fade": "Wygaszaj", + "navbar.custom.full": "Usuń jedno miejsce, aby dodać kolejne.", + "navbar.custom.hidden_design": "Ukryty w środowisku Projekt", + "navbar.custom.hidden_render": "Ukryty w środowisku Renderowanie", + "navbar.custom.hide": "Ukryj pasek nawigacji", + "navbar.custom.hide_idle": "Ukryj do najechania kursorem", + "navbar.custom.icon_only": "Tylko ikona", + "navbar.custom.icon_value": "Ikona i wartość", + "navbar.custom.icons": "Ikony", + "navbar.custom.idle": "W bezczynności", + "navbar.custom.intro": "Wybierz elementy sterujące na pasku u dołu widoku 3D i ich kolejność. Przeciągnij miejsce, aby je przesunąć, lub przeciągnij je poza pasek, aby je usunąć.", + "navbar.custom.labels": "Ikony i etykiety", + "navbar.custom.left": "Przesuń w lewo", + "navbar.custom.position": "Położenie", + "navbar.custom.position_centre": "Środek", + "navbar.custom.position_left": "Lewo", + "navbar.custom.position_right": "Prawo", + "navbar.custom.preview_as": "Podgląd jako", + "navbar.custom.regular": "Zwykły", + "navbar.custom.remove": "Usuń", + "navbar.custom.remove_bar": "Usuń z paska", + "navbar.custom.render": "Renderowanie", + "navbar.custom.reset": "Resetuj pasek nawigacji", + "navbar.custom.reset_default": "Przywróć domyślne", + "navbar.custom.right": "Przesuń w prawo", + "navbar.custom.select": "Nie wybrano miejsca. Zaznacz miejsce na pasku, aby je przesunąć, usunąć lub zmienić jego wygląd.", + "navbar.custom.separators_full": "Pasek ma już cztery separatory.", + "navbar.custom.show": "Pokaż pasek nawigacji", + "navbar.custom.shows": "Pokazuje", + "navbar.custom.size": "Rozmiar", + "navbar.custom.stale": "Pasek się zmienił. Zaznacz miejsce ponownie.", + "navbar.custom.title": "Pasek nawigacji", + "navbar.custom.unknown": "To miejsce jest niedostępne.", + "navbar.description.ambient_occlusion": "Przyciemnia zagłębienia i narożniki, dodając głębi.", + "navbar.description.display": "Zawiera ustawienia widoku: ustawienie wstępne grafiki, siatkę, cień na podłożu, okluzję otoczenia i rzutowanie.", + "navbar.description.fit": "Kadruje zaznaczenie lub cały model, jeśli nic nie jest zaznaczone.", + "navbar.description.grid": "Pokazuje lub ukrywa siatkę podłoża.", + "navbar.description.ground_shadow": "Pokazuje lub ukrywa cień pod modelem.", + "navbar.description.home": "Wraca do widoku izometrycznego całego modelu.", + "navbar.description.look_at": "Obraca widok przodem do zaznaczonej ściany, płaszczyzny lub otwartego szkicu.", + "navbar.description.orbit": "Obraca widok wokół modelu.", + "navbar.description.pan": "Przesuwa widok w bok, w górę lub w dół.", + "navbar.description.projection": "Przełącza między widokiem ortogonalnym a perspektywicznym.", + "navbar.description.separator": "Cienka linia grupująca sąsiednie miejsca.", + "navbar.description.spin": "Obraca widok bez przerwy, aż klikniesz lub naciśniesz klawisz.", + "navbar.description.visual_style": "Przełącza sposób rysowania modelu: cieniowany, krawędziowy lub z krawędziami niewidocznymi.", + "navbar.description.zoom": "Przybliża lub oddala widok od modelu.", + "navbar.display": "Wyświetlanie", + "navbar.fit": "Dopasuj", + "navbar.graphics": "Grafika", + "navbar.grid": "Siatka", + "navbar.ground_shadow": "Cień na podłożu", + "navbar.home": "Widok główny", + "navbar.later": "Później", + "navbar.look_at": "Patrz na", + "navbar.more": "Więcej", + "navbar.orbit": "Obracanie widoku", + "navbar.orthographic": "Ortogonalny", + "navbar.pan": "Przesuwanie", + "navbar.perspective": "Perspektywiczny", + "navbar.preset": "Ustawienie wstępne", + "navbar.projection": "Rzutowanie", + "navbar.projection_heading": "Rzutowanie", + "navbar.separator": "Separator", + "navbar.show": "Pokaż", + "navbar.spin": "Ciągłe obracanie", + "navbar.style.shaded": "Cieniowany", + "navbar.style.shaded_with_edges": "Cieniowany z krawędziami", + "navbar.style.shaded_with_hidden_edges": "Cieniowany z krawędziami niewidocznymi", + "navbar.style.wireframe": "Model krawędziowy", + "navbar.translucent": "Półprzezroczysty", + "navbar.value_tip": "{name} · {value}", + "navbar.visual_style": "Styl wizualny", + "navbar.zoom": "Powiększenie", "needs two joints": "wymaga dwóch złączy", + "network.allow_once": "Zezwól jednorazowo", + "network.always_allow": "Zawsze zezwalaj temu serwerowi", + "network.prompt_detail": "Ten projekt ma łącze do pliku w lokalizacji sieciowej {host}. Otwarcie go łączy się z tym komputerem i może wysłać do niego Twoje dane logowania systemu Windows. Zezwalaj tylko zaufanym serwerom.", + "network.prompt_title": "Lokalizacja sieciowa", "new configuration": "nowa konfiguracja", "new parameter": "nowy parametr", "no hole": "bez otworu", "no interference": "brak kolizji", "no joint that moves yet": "brak jeszcze ruchomego złącza", + "no motion study yet": "brak jeszcze badania ruchu", "no picture": "brak obrazu", + "no such joint": "nie ma takiego złącza", "none": "brak", + "not a move": "to nie jest przesunięcie", + "not a number": "to nie jest liczba", + "open a sketch first": "najpierw otwórz szkic", "parts.automatic_name": "automatycznie", + "parts.dimension.across_flats": "Rozwartość klucza", + "parts.dimension.bore": "Otwór", "parts.dimension.d": "Średnica nominalna", + "parts.dimension.head_diameter": "Średnica łba", + "parts.dimension.head_height": "Wysokość łba", "parts.dimension.height": "Wysokość", + "parts.dimension.inner": "Średnica wewnętrzna", + "parts.dimension.outer": "Średnica zewnętrzna", "parts.dimension.pitch": "Skok", + "parts.dimension.socket": "Rozmiar gniazda", + "parts.dimension.socket_depth": "Głębokość gniazda", "parts.dimension.thickness": "Grubość", "parts.dimension.width": "Szerokość", + "parts.family.bearing": "Łożysko kulkowe zwykłe (obrys)", + "parts.family.button_head_cap_screw": "Śruba z łbem półokrągłym z gniazdem sześciokątnym", + "parts.family.dowel_pin": "Kołek walcowy", + "parts.family.hex_bolt": "Śruba z łbem sześciokątnym", + "parts.family.hex_nut": "Nakrętka sześciokątna", + "parts.family.set_screw": "Wkręt dociskowy z gniazdem sześciokątnym, płaski", + "parts.family.socket_head_cap_screw": "Śruba z łbem walcowym z gniazdem sześciokątnym", + "parts.family.washer": "Podkładka zwykła", "pattern.alignment_2": "Wyrównanie 2", "pattern.alignment_hint": "Liczba obejmuje oryginał. Wyśrodkuj rozkłada kopie po obu stronach; Odwróć wybiera stronę z dodatkową kopią przy parzystej liczbie.", "pattern.direction_1": "Kierunek 1", "pattern.direction_2": "Kierunek 2", "pattern.flip_direction": "Odwróć kierunek", + "perf.click_for_details": "Kliknij, aby zobaczyć szczegóły: wykres czasu klatek, histogram i to, co narysował GPU", + "perf.draw_calls": "Wywołania rysowania", + "perf.line_segments": "Odcinki linii", + "perf.low_1": "1% najniższych", + "perf.renderer": "Renderer", + "perf.scene_build": "Budowanie sceny", + "perf.triangles": "Trójkąty", + "perf.ui_time": "Czas interfejsu", + "perf.viewport": "Widok", + "perf.worst": "najgorszy", + "pick a body or some of its faces": "wybierz bryłę lub niektóre jej ściany", "pick a hole to seat it, or OK places it at the origin": "wybierz otwór, aby go osadzić, lub OK umieszcza go w początku układu", + "pick a planar face": "wybierz płaską ścianę", + "pick a plane or a face as the tool": "wybierz płaszczyznę lub ścianę jako narzędzie", + "pick a size": "wybierz rozmiar", + "pick edges of a body": "wybierz krawędzie bryły", + "pick edges of one body": "wybierz krawędzie jednej bryły", + "pick edges of the holes to remove": "wybierz krawędzie otworów do usunięcia", + "pick faces of one body": "wybierz ściany jednej bryły", + "pick faces to offset": "wybierz ściany do odsunięcia", + "pick lines of one sketch": "wybierz linie jednego szkicu", "pick one or two vertices, edges or faces": "wybierz jeden lub dwa wierzchołki, krawędzie lub ściany", + "pick one plane or face as the tool, or sketch curves": "wybierz jedną płaszczyznę lub ścianę jako narzędzie albo krzywe szkicu", + "pick sections in two or more sketches": "wybierz przekroje w dwóch lub więcej szkicach", + "pick sketch curves or edges": "wybierz krzywe szkicu lub krawędzie", + "pick the face to untrim": "wybierz ścianę do cofnięcia przycięcia", + "pick the path": "wybierz ścieżkę", + "pick what to keep": "wybierz, co zachować", + "pick what to keep of the other surface": "wybierz, co zachować z drugiej powierzchni", "prefs.add_item": "Dodaj element", "prefs.alt_frees_manipulators": "Alt / Option zwalnia również strzałki manipulatora", + "prefs.anti_aliasing_amd_dx12": "Wygładzanie krawędzi używa 1×, ponieważ test przy uruchomieniu nic nie narysował z wielopróbkowaniem i przeszedł przy 1×. Możesz wybrać inne ustawienie powyżej; uruchom ponownie, aby je zastosować, jeśli test przejdzie.", "prefs.anti_aliasing_note": "Wygładza poszarpane krawędzie.", "prefs.applies_on_restart": "Działa po ponownym uruchomieniu", "prefs.assistant": "Asystent", + "prefs.auto_operation": "Wybieraj operację automatycznie", + "prefs.auto_operation_note": "Okna funkcji wybierają Połącz, Nowa bryła lub Wytnij na podstawie tego, czego dotyka nowy materiał, dopóki nie wybierzesz sam. Wyłączone: zachowują operację, z którą się otwierają.", "prefs.axes": "Osie", "prefs.axis": "Oś", + "prefs.bodies_folders": "Foldery brył", + "prefs.bodies_folders_always": "Zawsze", + "prefs.bodies_folders_never": "Nigdy", + "prefs.bodies_folders_several": "Tylko przy kilku bryłach", "prefs.bottom": "Dół", + "prefs.bottom_centre": "Dół pośrodku", + "prefs.bottom_right": "Dół z prawej", "prefs.browser": "Przeglądarka", + "prefs.browser_reveal_on_hover": "Pokazuj po najechaniu", + "prefs.browser_show_note": "Widoczna utrzymuje przeglądarkę na ekranie. Pokazuj po najechaniu ukrywa ją, dopóki wskaźnik nie znajdzie się w jej miejscu lub na cienkiej karcie przy jej lewej krawędzi; pozostaje otwarta, gdy wskaźnik jest w niej lub otwarte jest jej menu.", + "prefs.browser_shown": "Widoczna", "prefs.built_in": "Wbudowana", "prefs.button_left": "Przeciąganie lewym", "prefs.button_middle": "Przeciąganie środkowym", @@ -1105,6 +1853,11 @@ "prefs.design": "Ustawienia domyślne projektu", "prefs.design_note": "Ustawienia, z którymi startują nowe projekty. Otwarte projekty zachowują własne.", "prefs.devices": "Mysz 3D", + "prefs.dialog_buttons": "Przyciski okien dialogowych", + "prefs.dialogs": "Okna dialogowe", + "prefs.dialogs_edge": "Przy krawędzi widoku", + "prefs.dialogs_left_column": "W lewej kolumnie", + "prefs.dialogs_note": "Gdzie otwierają się okna poleceń: zadokowane przy krawędzi widoku albo w lewej kolumnie w miejscu przeglądarki i Historii, które wracają na swoje miejsce po zamknięciu okna.", "prefs.display": "Wyświetlanie", "prefs.edges": "Krawędzie i cieniowanie", "prefs.extend": "Rozszerz", @@ -1120,12 +1873,25 @@ "prefs.gesture_pan": "Przesuwanie", "prefs.gesture_zoom": "Powiększanie", "prefs.graphics": "Grafika", + "prefs.graphics_card": "Karta graficzna", + "prefs.graphics_card_auto": "Automatycznie", + "prefs.graphics_card_missing": "Zapisana karta graficzna jest niedostępna; używane jest ustawienie Automatycznie.", + "prefs.graphics_card_restart": "Działa po ponownym uruchomieniu SolidCraft. WGPU_ADAPTER_NAME ma pierwszeństwo przed tym wyborem.", "prefs.graphics_note": "Wybierz między szybkością a gładkością. Każda opcja pokazuje swój efekt na przykładzie.", "prefs.graphics_presets_note": "Ustawienia wstępne zmieniają razem poniższe opcje.", "prefs.history": "Historia", + "prefs.history_origin_bodies": "Pokazuj początek układu i bryły na górze", + "prefs.history_origin_bodies_note": "Historia po lewej lub pod przeglądarką zaczyna się od folderu Bryły aktywnego komponentu i Początku układu, z ikonami oka i menu jak w przeglądarce. Historia na dole pozostaje bez zmian.", + "prefs.history_play_button": "Przycisk Odtwórz historię w elementach sterujących Historii", + "prefs.history_play_button_note": "Umieszcza Odtwórz historię między Cofnij o jeden krok a Przejdź o jeden krok do przodu w elementach sterujących Historii. Możesz też kliknąć je prawym przyciskiem, aby go pokazać lub ukryć.", + "prefs.history_sketches": "Szkice", + "prefs.history_sketches_in_order": "W kolejności", + "prefs.history_sketches_note": "Gdy Szkice są ustawione na Pod swoją funkcją, Historia po lewej pokazuje każdy szkic o poziom głębiej, pod pierwszą zbudowaną z niego funkcją, o ile jest dość szeroka na nazwy. Oś czasu na dole zawsze zachowuje kolejność tworzenia.", + "prefs.history_sketches_under_feature": "Pod swoją funkcją", "prefs.hold_stop_snapping": "Przytrzymaj, aby wyłączyć przyciąganie", "prefs.icons_only": "Tylko ikony", "prefs.imported_file": "Zaimportowany plik", + "prefs.in_browser": "W przeglądarce", "prefs.input": "Wprowadzanie", "prefs.interface": "Interfejs", "prefs.interface_language": "Język interfejsu", @@ -1136,8 +1902,14 @@ "prefs.layout_note": "Kompaktowy dokuje panele do krawędzi okna. Przezroczystość przeglądarki jest dostępna w układzie Pływający. Stronę asystenta zmienisz na stronie Asystent.", "prefs.left": "Lewo", "prefs.left_click": "Kliknięcie lewym", + "prefs.line_drag_draws_one": "Naciśnięcie i przeciągnięcie rysuje jedną linię", + "prefs.line_drag_draws_one_note": "W narzędziu Linia naciśnięcie, przeciągnięcie i puszczenie rysuje jedną linię od miejsca naciśnięcia do puszczenia i na tym się kończy. Kliknięcia nadal rysują łańcuch. Wyłączone: przeciągnięcie stawia punkt w miejscu puszczenia, tak jak kliknięcie.", + "prefs.linked_designs": "Projekty połączone", "prefs.live_input": "Bieżące wejście", "prefs.live_preview": "Podgląd na żywo", + "prefs.logo": "Logo", + "prefs.logo_accent": "Akcent", + "prefs.logo_theme": "Kolory motywu", "prefs.max_file_size": "Maksymalny rozmiar otwieranego pliku", "prefs.max_file_size_design": "Pliki projektów", "prefs.max_file_size_flag": "To uruchomienie używa --max-file-size {size} aż do zamknięcia SolidCraft. Te ustawienia obowiązują przy uruchomieniu bez tej opcji.", @@ -1152,23 +1924,56 @@ "prefs.mouse_mapping": "Przypisanie przycisków myszy", "prefs.navigation": "Nawigacja", "prefs.navigation_note": "Jak mysz, gładzik i mysz 3D poruszają widokiem.", + "prefs.network_host_remove": "Usuń", + "prefs.network_locations": "Zezwalaj na lokalizacje sieciowe w projektach", + "prefs.network_locations_note": "Wyłączone: połączone pliki projektu na innym komputerze (\\\\serwer\\udział) są otwierane tylko z serwerów wymienionych tutaj albo po zezwoleniu, gdy pojawi się pytanie.", + "prefs.network_locations_section": "Lokalizacje sieciowe", "prefs.not_on_gpu": "niedostępne na tym GPU", "prefs.on": "Włączone", "prefs.opaque": "Nieprzezroczysta", "prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.", "prefs.orientation": "Orientacja", + "prefs.origin_plane_names": "Nazwy płaszczyzn początku układu", + "prefs.origin_plane_names_note": "Nazwy pokazywane w przeglądarce, oknach dialogowych i listach. {top} to XY, {front} to XZ, a {right} to YZ, przy osi Z w górę. Polecenia, skrypty i zapisane projekty zachowują XY, XZ i YZ.", "prefs.overall_speed": "Szybkość ogólna", + "prefs.perspective_fov": "Pole widzenia", "prefs.placement": "Umieszczanie", + "prefs.prefer_free_sketch_points": "Przy nakładających się punktach preferuj ruchome punkty szkicu", "prefs.preview_choices": "Podgląd wybranych opcji", + "prefs.print_face_highlight": "Wyróżnienie powierzchni druku", + "prefs.print_face_highlight_tip": "Jak mocno powierzchnia druku bryły jest barwiona i obrysowywana w widoku. 0 % nie pokazuje wyróżnienia.", + "prefs.profile_active": "Aktywny profil", + "prefs.profile_applied": "Zastosowano profil.", + "prefs.profile_changed": "Ustawienia zmieniły się od zapisania profilu. Zapisz zachowa je w nim.", + "prefs.profile_deleted": "Usunięto profil.", + "prefs.profile_export": "Eksportuj…", + "prefs.profile_exported": "Wyeksportowano profil.", + "prefs.profile_imported": "Zaimportowano profil. Wybierz go w polu Aktywny profil, aby się na niego przełączyć.", + "prefs.profile_name_hint": "Nazwa profilu, np. Laptop", + "prefs.profile_rename": "Zmień nazwę", + "prefs.profile_renamed": "Zmieniono nazwę profilu.", + "prefs.profile_save": "Zapisz", + "prefs.profile_saved": "Zapisano bieżące ustawienia w profilu.", + "prefs.profile_skipped": "Zastosowano profil; ustawienia, które zachowały bieżącą wartość: {n} (zobacz dziennik).", + "prefs.profiles": "Profile ustawień", + "prefs.profiles_note": "Skróty, pasek narzędzi, nawigacja, menu promieniste i ustawienia interfejsu, zebrane pod jedną nazwą i przełączane naraz.", + "prefs.project_options_dialog": "Okno opcji rzutowania", + "prefs.project_options_dialog_note": "Włączone: przed rzutowaniem wybierz Geometria lub Bryły oraz Powiązanie rzutu. Wyłączone: zachowaj poprzedni sposób bezpośredniego wybierania.", "prefs.quality_default": "domyślnie", "prefs.quality_fastest": "najszybciej", "prefs.quality_slowest": "najwolniej", "prefs.quality_smoother": "gładziej", + "prefs.render_previous_geometry_budget": "Używaj poprzedniego budżetu geometrii podglądu renderowania", + "prefs.render_previous_geometry_budget_help": "Przywraca poprzedni stały limit geometrii (512 MiB na 64-bitowym komputerze stacjonarnym, 128 MiB w innych przypadkach) zamiast budżetu zależnego od urządzenia.", "prefs.reports_saved": "Zapisane: {n}", + "prefs.ribbon_reveal": "Pokazuj wyniki wyszukiwania na wstążce", + "prefs.ribbon_reveal_note": "Najechanie na wynik w Szukaj poleceń pokazuje, gdzie polecenie znajduje się na wstążce: jego kartę, przycisk lub wiersz w rozwijanej liście panelu. Polecenie uruchomione z wyszukiwania miga w tym miejscu, a działające polecenie z rozwijanej listy oznacza swój panel.", "prefs.right_click": "Kliknięcie prawym", "prefs.saving": "Autozapis i odzyskiwanie", "prefs.scale_note": "Także Ctrl + / Ctrl − w oknie. Przyciski mają 28 px przy 100%.", "prefs.scale_text": "Skala i tekst", + "prefs.search": "Szukaj w preferencjach", + "prefs.search_empty": "Brak pasujących preferencji", "prefs.select": "Zaznacz", "prefs.select_one_item": "Zaznacz jeden element", "prefs.selecting": "Zaznaczanie", @@ -1176,14 +1981,38 @@ "prefs.shift_add_ctrl_toggle": "Shift tylko dodaje; Ctrl / Cmd przełącza", "prefs.shift_add_note": "Włączone: Shift dodaje, Ctrl / Cmd przełącza. Wyłączone (domyślnie): oba przełączają.", "prefs.shortcuts": "Skróty", + "prefs.single_surface_analysis": "Używaj jednej analizy powierzchni", + "prefs.single_surface_analysis_help": "Gdy wyłączone, cieniowanie powierzchni i Ciągłość mogą być włączone jednocześnie. Włączenie zachowuje ostatnio uruchomioną lub zmienioną analizę.", + "prefs.sketch_align_points": "Wyrównuj do innych punktów szkicu podczas rysowania", + "prefs.sketch_align_points_note": "Podczas rysowania punkt wyrównuje się jednocześnie poziomo z jednym punktem szkicu i pionowo z innym, z kropkowaną linią pomocniczą, a kliknięcie zachowuje oba wyrównania. Wyłączone: tylko z poprzednim punktem, po jednej osi.", + "prefs.sketch_conflict_colour": "Geometria w konflikcie w kolorze konfliktu", + "prefs.sketch_conflict_colour_note": "Gdy wiązania szkicu są w konflikcie, krzywe i punkty trzymane przez niespełnione wiązanie też są rysowane w kolorze konfliktu, a nie tylko jego symbol lub wymiar. Wyłączone: zachowują zwykłe kolory.", + "prefs.sketch_exit_view": "Po zakończeniu szkicu", + "prefs.sketch_exit_view.keep": "Zachowaj bieżący widok", + "prefs.sketch_exit_view.previous": "Wróć do poprzedniego widoku", + "prefs.sketch_exit_view.unless_rotated": "Wróć, chyba że obrócono widok", + "prefs.sketch_exit_view_note": "Dokąd przechodzi widok po Zakończ szkic, Anuluj lub podwójnym Esc.", "prefs.sketch_selection": "Szkic i zaznaczanie", "prefs.sketch_selection_heading": "Szkic i zaznaczanie", "prefs.sketch_selection_note": "Jak działa przyciąganie w szkicach i jak kliknięcia zaznaczają.", + "prefs.sketch_state_icons": "Ikony szkiców w kolorze ich stanu", + "prefs.sketch_state_icons_note": "W Historii i przeglądarce ikona szkicu przyjmuje kolor jego stanu (niedostatecznie związany, w pełni związany lub w konflikcie), a podpowiedź podaje ten stan. Wyłączone: każdy szkic ma tę samą ikonę.", + "prefs.sketch_tab_always": "Zawsze pokazuj kartę Szkic", + "prefs.sketch_tab_always_note": "Karta Szkic pozostaje na wstążce, zaraz po Model, nawet gdy żaden szkic nie jest otwarty. Jej narzędzia rysunkowe zaczynają wtedy szkic na zaznaczonej płaszczyźnie lub ścianie albo na następnej wybranej płaszczyźnie, z gotowym narzędziem.", + "prefs.sketch_tracking_guides": "Linie pomocnicze wyrównania do środków i punktów środkowych", + "prefs.sketch_tracking_guides_note": "Najedź na środek okręgu lub punkt środkowy prostej krawędzi, a potem przesuń w poziomie lub pionie, aby pokazać tymczasową kropkowaną linię pomocniczą.", "prefs.sketching": "Szkicowanie", "prefs.snap_drawing_note": "Podczas rysowania lub przeciągania punktu szkicu.", "prefs.source": "Źródło", "prefs.speed": "Szybkość", "prefs.stand_up_note": "Gdy plik STEP lub IGES otworzy się z osią Z w górę, proponuj obrót o ćwierć obrotu wokół osi X.", + "prefs.stand_up_unknown": "Dla plików z nieznanych programów", + "prefs.stand_up_unknown.ask": "Pytaj w oknie dialogowym", + "prefs.stand_up_unknown.note": "Pokaż uwagę", + "prefs.stand_up_unknown.off": "Nie proponuj", + "prefs.stand_up_unknown_note": "Jak składana jest propozycja, gdy nagłówek pliku nie wskazuje programu, o którym wiadomo, że zapisuje z osią Y w górę. Uwaga pojawia się tylko, gdy plik wydaje się leżeć na plecach; Pytaj w oknie dialogowym pyta o każdy taki plik, jak dotąd.", + "prefs.status_bar": "Pasek stanu", + "prefs.status_bar_note": "Cienki pasek wzdłuż dolnej krawędzi okna pokazuje, co jest edytowane, stan otwartego szkicu w jego kolorze i jednostki projektu; kliknięcie jednostek je zmienia. Stan szkicu nie jest wtedy pokazywany w rogu widoku.", "prefs.status_done": "Gotowe", "prefs.status_error": "Błąd", "prefs.status_selected": "Zaznaczone", @@ -1191,27 +2020,47 @@ "prefs.supersampling_brief": "Rysuje widok w większym rozmiarze i go zmniejsza: bardzo ostro, ale bardziej obciąża.", "prefs.tab_accent_note": "Kolor akcentu podąża za aktywną kartą", "prefs.theme_file": "plik motywu", + "prefs.theme_file_or_paste": "plik lub wklejenie", "prefs.theme_follow": "Śledź plik motywu", "prefs.theme_follow_error": "Nie zastosowano: {error}. Ostatni poprawny motyw pozostaje aktywny.", "prefs.theme_follow_launch": "Śledzenie tylko w tej sesji (--theme-file); plik jest wczytywany ponownie po każdym nadpisaniu przez inne narzędzie.", "prefs.theme_follow_note": "Plik jest wczytywany ponownie po każdym nadpisaniu przez inne narzędzie. Wybór motywu kończy śledzenie.", "prefs.theme_follow_off": "Wyłączone", "prefs.theme_follow_stop": "Zakończ śledzenie", + "prefs.theme_import_hint": "Kliknij, aby wybrać plik motywu, upuść go tutaj albo naciśnij Ctrl+V (Cmd+V na Macu), mając skopiowany motyw", + "prefs.theme_paste": "Wklej motyw", + "prefs.theme_paste_none": "Schowek nie zawiera motywu SolidCraft", "prefs.timeline_half_hour": "−30 min", "prefs.timeline_now": "teraz", "prefs.timeline_quarter_hour": "−15", + "prefs.toast_place": "Powiadomienia", "prefs.toggle_item": "Dodaj element lub usuń go ponownie", "prefs.toolbar": "Pasek narzędzi", + "prefs.toolbar_tabs": "Karty paska narzędzi", + "prefs.top": "Góra", + "prefs.trackpad_zoom_modifier": "Powiększaj przewijaniem dwoma palcami, trzymając", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Przytrzymaj tylko ten klawisz, aby powiększać w miejscu wskaźnika przewijaniem w pionie. Wyłączone zachowuje dotychczasowe przewijanie.", + "prefs.trackpad_zoom_modifier.off": "Wyłączone", "prefs.translucent": "Półprzezroczysta", "prefs.under_browser": "Pod przeglądarką", "prefs.under_cursor": "Pod kursorem", "prefs.units_precision": "Jednostki i dokładność", "prefs.updates": "Aktualizacje", "prefs.view": "Zachowanie widoku", + "prefs.view_confirm_corner": "OK i Anuluj w rogu widoku", + "prefs.view_confirm_corner_note": "Gdy otwarte jest okno dialogowe lub szkic, znaczek i krzyżyk w prawym górnym rogu widoku działają jak OK i Anuluj w oknie albo kończą i anulują szkic. Nawigator widoku przesuwa się pod nie.", "prefs.viewport": "Widok", + "prefs.viewport_background": "Tło", + "prefs.viewport_background_colour": "Kolor", + "prefs.viewport_background_theme": "Gradient motywu", "prefs.wheel": "Kółko", "prefs.wheel_trackpad": "Kółko i gładzik", "prefs.workspace": "Środowisko pracy", + "prefs.zoom_stop_at_model": "Zatrzymuj powiększanie na modelu", + "prefs.zoom_stop_at_model_tip": "Mysz 3D i aplikacja urządzenia powiększają w stronę powierzchni na środku widoku; powiększanie przeciąganiem trzyma punkt, w którym zaczęło się przeciąganie. Wyłączone: powiększanie może przejść przez model. Kółko i szczypanie osobno używają Powiększaj w stronę kursora", "prefs.zoom_toward_cursor": "Powiększanie w stronę kursora", "properties.title": "Właściwości: {name}", "recovery.days_ago": "{count} dni temu", @@ -1283,8 +2132,34 @@ "search.suggest": "Czy chodziło o {label}?", "search.try": "Wpisz nazwę polecenia, element tego projektu lub parametr. Aby uruchomić polecenie z wartościami, wpisz jego identyfikator i obiekt JSON: {example}", "search.valid": "Prawidłowe", + "section.off_hint": "Nie wybrano płaszczyzny: OK wyłącza przekrój.", + "select a direction (an axis, a sketch line or a straight edge) first": "najpierw zaznacz kierunek (oś, linię szkicu lub prostą krawędź)", + "select a direction (an axis, a sketch line or a straight edge) for the profile to follow": "zaznacz kierunek (oś, linię szkicu lub prostą krawędź), za którym ma podążać profil", + "select a point on a component": "zaznacz punkt na komponencie", + "select a profile, edges or sketch lines first": "najpierw zaznacz profil, krawędzie lub linie szkicu", + "select an axis": "zaznacz oś", + "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "najpierw zaznacz oś, prostą lub okrągłą krawędź albo ścianę walca, stożka lub torusa", + "select bodies first": "najpierw zaznacz bryły", + "select components first": "najpierw zaznacz komponenty", + "select corner points, or the two lines of one corner": "zaznacz punkty naroży lub dwie linie jednego naroża", + "select one corner point, or the two lines of one corner": "zaznacz jeden punkt naroża lub dwie linie jednego naroża", + "select sheet edges first": "najpierw zaznacz krawędzie blachy", + "select the bend line (a sketch line) first": "najpierw zaznacz linię gięcia (linię szkicu)", + "select the cam's profile (a sketch profile or the cam's edges)": "zaznacz profil krzywki (profil szkicu lub krawędzie krzywki)", + "select the corner to cut first": "najpierw zaznacz naroże do ścięcia", + "select the corners to round first": "najpierw zaznacz naroża do zaokrąglenia", + "select the fixed face first (a plate's large face)": "najpierw zaznacz ścianę stałą (dużą ścianę płyty)", + "select the frame sketch's lines first": "najpierw zaznacz linie szkicu ramy", + "select the path (sketch lines across the edge)": "zaznacz ścieżkę (linie szkicu w poprzek krawędzi)", + "select the rim face first": "najpierw zaznacz ścianę obrzeża", + "select the sheet edge first": "najpierw zaznacz krawędź blachy", + "select two components": "zaznacz dwa komponenty", + "set by the sketch's lines": "wyznaczone przez linie szkicu", "settings.display_quality_note": "Wyższe poziomy rysują zakrzywione ściany i krawędzie gładziej, ale siatka powstaje dłużej. Zmienia się tylko widok, nigdy projekt ani eksport.", + "settings.double_click_components": "Kliknij dwukrotnie komponent, aby go aktywować lub edytować połączony komponent w miejscu", + "settings.frame_rate_details_note": "Dodaje wykres czasów ostatnich klatek z nałożoną liczbą rysowanych trójkątów, histogram czasów klatek i to, co narysował GPU. Kliknij liczbę klatek w widoku, aby je pokazać lub ukryć.", "settings.frame_rate_note": "Pokazuje liczbę klatek na sekundę w prawym dolnym rogu widoku.", + "settings.insert_lists_open_designs": "Wstaw projekt pokazuje najpierw projekty otwarte w innych kartach", "settings.measure_axis_distances": "Pokaż odległości X/Y/Z", "settings.measure_axis_distances_help": "Gdy pomiar jest otwarty, pokazuj składowe wzdłuż osi globalnych obok odległości całkowitej.", "settings.msaa_applies_on_restart": "Wygładzanie krawędzi zadziała po ponownym uruchomieniu SolidCraft", @@ -1295,10 +2170,19 @@ "settings.reset_intro": "Te ustawienia nie działają na tym komputerze i uniemożliwiłyby uruchomienie SolidCraft, więc zostały zmienione:", "settings.reset_title": "Zmieniono ustawienia", "settings.supersampling_note": "Rysuje widok w wyższej rozdzielczości i go zmniejsza: gładsze krawędzie, linie i siatka na ekranach poniżej 4K. Kosztuje czas GPU: ok. 2,25× więcej pikseli przy 1,5× i 4× przy 2×.", + "settings.update_changed_links": "Aktualizuj zmienione połączone projekty po powrocie do karty", + "settings.window_system_details": "Szczegóły", + "settings.window_system_note_now": "Działa po ponownym uruchomieniu. Teraz: {now}.", + "settings.window_system_wayland_limited": "Niektóre funkcje są ograniczone w natywnym Wayland.", + "sheet.flange_type_base": "Bazowy", + "sheet.flange_type_contour": "Konturowy", + "sheet.flange_type_edge": "Krawędziowy", + "sheet.no_body": "nie ma bryły blaszanej: zacznij ją przez Kołnierz > Bazowy albo zamień płytę w blachę przez Konwertuj na blachę (wybierz jej dużą płaską ścianę)", "shortcut.json_error": "parametry muszą być w formacie JSON: {error}", "shortcut.no_command": "nie ma polecenia `{id}`; tekst przed JSON musi być identyfikatorem polecenia", "shortcut.pin": "Przypnij na górze pola S", "shortcut.unpin": "Odepnij", + "sketch is missing": "brak szkicu", "sketch.degrees_of_freedom": "Stopnie swobody: {count}", "sketch.dimension_added_driven": "Szkic już określa ten wymiar, więc dodano go jako wymiar sterowany (referencyjny)", "sketch.dimension_applied": "Wymiar zastosowany", @@ -1309,11 +2193,18 @@ "sketch.draw_curves": "Rysuj jako {linetype}", "sketch.make_selection": "Zamień zaznaczenie na {linetype}", "sketch.open_ends": "Otwarte: {count}", + "sketch.status_conflict": "wiązania w konflikcie", + "sketch.status_fully_constrained": "w pełni związany", "sketch.untied_ends": "Niezwiązane: {count}", "sketch_panel.attached": "Dołączony do {body} / {path}", "sketch_panel.author": "Pierwotne położenie: {path}", "sketch_panel.dimension_hint": "Wprowadź wartość lub wyrażenie, a następnie naciśnij OK.", "sketch_panel.move_hint": "Wprowadź odległości i kąt, a następnie naciśnij OK.", + "sketch_panel.plane_angle": "{plane}, obrócona o {angle}", + "sketch_panel.plane_fixed": "Stała płaszczyzna", + "sketch_panel.plane_flipped": "{plane}, od drugiej strony", + "sketch_panel.plane_offset": "{plane}, odsunięta o {distance}", + "sketch_panel.plane_origin": "Płaszczyzna {plane}", "sketch_panel.redefine_title": "{command}: {name}", "sketch_panel.sheet_attachment": "Dołączony do płaskiej ściany bryły blaszanej; podąża za tym panelem przy zmianach blachy.", "sketch_panel.text_hint": "Edytuj tekst i jego położenie, a następnie naciśnij OK.", @@ -1330,29 +2221,74 @@ "sketch_text.path_hint": "Krzywa szkicu, wzdłuż której biegnie tekst: kliknij tutaj, a potem krzywą", "sketch_text.use_font_file": "Użyj pliku czcionki {path}", "solid": "bryła", + "status.editing": "Edycja: {name}", + "status.navigation": "Przypisanie myszy: {mapping}", + "status.navigation_tip": "Jak mysz porusza widokiem. Kliknij, aby wybrać inne przypisanie", + "status.under_constrained": "Niedostatecznie związany (stopnie swobody: {count})", + "status.under_constrained_one": "Niedostatecznie związany (stopnie swobody: {count})", + "status.units_tip": "Jednostki projektu. Kliknij, aby je zmienić", + "the angle must be a number": "kąt musi być liczbą", + "the curves must be in one sketch": "krzywe muszą leżeć w jednym szkicu", + "the direction must be an axis, a sketch line or a straight edge": "kierunek musi być osią, linią szkicu lub prostą krawędzią", + "the duration must be a number of seconds, 0.001 or more": "czas trwania musi być liczbą sekund, co najmniej 0,001", + "the line's sketch is gone": "szkic linii już nie istnieje", + "the lines must be in one sketch": "linie muszą leżeć w jednym szkicu", + "the lines must be in one sketch, not the section's": "linie muszą leżeć w jednym szkicu, innym niż szkic przekroju", + "the parts library is empty": "biblioteka części jest pusta", "the parts library is not available": "biblioteka części jest niedostępna", + "the path curves must be in one other sketch": "krzywe ścieżki muszą leżeć w jednym, innym szkicu", + "the path's sketch is gone": "szkic ścieżki już nie istnieje", + "the position must be a number": "położenie musi być liczbą", + "the preview failed": "podgląd się nie powiódł", + "the profiles and curves must be in one sketch": "profile i krzywe muszą leżeć w jednym szkicu", + "the reference face must be on the edges' body": "ściana odniesienia musi leżeć na bryle tych krawędzi", + "the scale must be above 0": "skala musi być większa od 0", + "the section's profiles must be in one sketch": "profile przekroju muszą leżeć w jednym szkicu", + "the two origins are on the same component": "oba początki leżą na tym samym komponencie", "thread.automatic_size": "Rozmiar automatyczny", "thread.designation": "Oznaczenie", + "thread.direction": "Kierunek gwintu", "thread.iso_metric": "Metryczny ISO", + "thread.left_hand": "Lewy", + "thread.right_hand": "Prawy", "thread.select_size": "Wybierz rozmiar", "timeline.delete_after_marker_one": "Usuń 1 funkcję za znacznikiem historii", "timeline.delete_after_marker_other": "Usuń funkcje za znacznikiem historii: {count}", + "timeline.delete_folders_tip": "Zachowaj ich funkcje i zagnieżdżone foldery w historii", + "timeline.delete_folders_with_features_tip": "Usuń foldery razem ze wszystkimi funkcjami w nich i zagnieżdżonymi folderami", "timeline.delete_many": "Usuń zaznaczone funkcje: {count}", + "timeline.edited_rolled_back": "{name} się zmieniła, ale leży za znacznikiem historii, więc nie jest budowana: przesuń znacznik za nią, aby zobaczyć wynik", + "timeline.feature_error": "Błąd: {message}", + "timeline.feature_hint": "Kliknij dwukrotnie, aby edytować · przeciągnij, aby zmienić kolejność · prawy przycisk, aby zobaczyć więcej", + "timeline.feature_rolled_back": "Cofnięta: leży za znacznikiem historii, więc nie jest budowana", + "timeline.feature_warning": "Ostrzeżenie: {message}", "timeline.group": "Nowy folder funkcji", "timeline.move_many": "Przenieś elementy osi czasu", "timeline.move_to_group": "Przenieś funkcje do folderu", "timeline.rename_group": "Zmień nazwę folderu funkcji", "timeline.reorder_many": "Zmień kolejność funkcji", + "timeline.suppress_features": "Wygaś funkcje", + "timeline.suppress_folders_tip": "Wygaś lub przywróć każdą funkcję w folderach i ich zagnieżdżonych folderach", "timeline.ungroup": "Usuń foldery funkcji", + "timeline.unsuppress_features": "Przywróć wygaszone funkcje", "to the face": "do ściany", "to the next face": "do następnej ściany", "toolbar.customize_note": "Karta {tab}. Zaznacz polecenie, aby dodać jego przycisk do paska narzędzi. Możesz też przeciągnąć wiersz z listy rozwijanej panelu na pasek narzędzi, przeciągnąć przycisk poza pasek, aby go usunąć, lub użyć pinezki w wierszu listy rozwijanej.", "toolbar.image": "OBRAZ", "toolbar.render": "RENDEROWANIE", + "toolbar.render_appearance": "WYGLĄD", + "toolbar.render_camera": "KAMERA", + "toolbar.render_environment": "OTOCZENIE", + "toolbar.render_lighting": "OŚWIETLENIE", + "toolbar.render_lights": "ŚWIATŁA", + "toolbar.render_result": "WYNIK", "toolbar.render_scene": "SCENA", + "toolbar.render_stage": "SCENOGRAFIA", + "toolbar.render_studio": "STUDIO", "trace.points": "Punkty wzdłuż ścieżki: {count}", "trace.stopped_by_contact": "Kończy się na próbce {sample}: {a} przeszedłby przez {b}", "trace.stopped_by_loop": "Kończy się na próbce {sample}: pętla przez {joint} nie może się bardziej zamknąć", + "trim by sketch curves, or by one plane or face": "przytnij krzywymi szkicu albo jedną płaszczyzną lub ścianą", "ui.1_degree_of_freedom": "1 stopień swobody", "ui.3d_mouse_axes_reassigned": "Osie są przypisane na nowo w Ustawieniach i to nadal obowiązuje razem z tą kalibracją.", "ui.3d_mouse_button": "Przycisk {number}", @@ -1406,6 +2342,10 @@ "ui.3d_mouse_offer": "Kalibracja myszy 3D naprawia zamienione lub odwrócone osie oraz ustawia jej szybkość i odczucie.", "ui.3d_mouse_offer_never": "Nie pokazuj więcej", "ui.3d_mouse_offer_swap": "Przełącz na Hidraw (sterownik bezpośredni)", + "ui.3d_mouse_orbit": "Obracanie widoku", + "ui.3d_mouse_orbit_classic": "Klasyczne", + "ui.3d_mouse_orbit_tip": "Śledzone: mysz 3D obraca widok wokół części modelu, która jest na środku widoku w chwili rozpoczęcia obrotu, oznaczonej bladą kulą. Klasyczne: wokół środka widoku", + "ui.3d_mouse_orbit_traced": "Śledzone", "ui.3d_mouse_reading": "Odczyt", "ui.3d_mouse_reset_axes": "Przywróć przypisanie osi", "ui.3d_mouse_reset_view": "Resetuj widok", @@ -1442,6 +2382,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Obróć nakładkę przeciwnie do ruchu wskazówek zegara, przytrzymaj, a następnie puść.", "ui.3d_mouse_switch_cap": "Kalibruj jako powrót do środka", "ui.3d_mouse_switch_trackball": "Kalibruj jako trackball", + "ui.3d_mouse_use_direct": "Użyj sterownika bezpośredniego", + "ui.3d_mouse_use_direct_tip": "Odczytuj mysz 3D bezpośrednio (hidraw) zamiast przez spacenavd.", "ui.3d_mouse_view_axis": "Oś widoku", "ui.3d_mouse_view_off": "Wyłączona", "ui.3d_mouse_view_off_tip": "Profil sterownika myszy 3D wyłącza tę oś, więc jej ruch nie porusza widokiem. Wybranie tu ruchu innej osi zamienia je miejscami, a ten ruch przestaje poruszać widokiem.", @@ -1467,6 +2409,8 @@ "ui.agent_chat_allowed": "Zezwolono", "ui.agent_chat_answered": "Odpowiedź: {option}", "ui.agent_chat_approval": "Zezwolić agentowi na to działanie?", + "ui.agent_chat_attach_selection": "Dołącz zaznaczenie", + "ui.agent_chat_attach_selection_tip": "Wyślij z wiadomością to, co zaznaczono w modelu (ściany, krawędzie, funkcje, bryły), aby agent wiedział, o co chodzi", "ui.agent_chat_attach_view": "Dołącz widok", "ui.agent_chat_attach_view_tip": "Wyślij z wiadomością obraz bieżącego widoku", "ui.agent_chat_attachable": "Obrazy, pliki PDF, tekstowe i CAD", @@ -1479,11 +2423,18 @@ "ui.agent_chat_auth_title": "Zaloguj się do agenta", "ui.agent_chat_cancel": "Anuluj", "ui.agent_chat_changes": "Zmiany: {n}, jeden krok cofania", + "ui.agent_chat_changes_n": "Zmiany: {n}", "ui.agent_chat_changes_of": "Zmiany: {n}/{limit}", + "ui.agent_chat_choose_model": "Wybierz model", "ui.agent_chat_close": "Zamknij", "ui.agent_chat_command": "Polecenie agenta", "ui.agent_chat_connect": "Połącz", "ui.agent_chat_copy": "Kopiuj", + "ui.agent_chat_ctx_bodies": "Bryły", + "ui.agent_chat_ctx_elements": "Elementy", + "ui.agent_chat_ctx_features": "Funkcje", + "ui.agent_chat_ctx_more": "+{n} więcej", + "ui.agent_chat_ctx_other": "Inne", "ui.agent_chat_custom_hint": "Dowolny program, który obsługuje Agent Client Protocol przez stdin i stdout. Nie jest uruchamiany przez powłokę.", "ui.agent_chat_denied": "Odmówiono", "ui.agent_chat_deny": "Odmów", @@ -1519,12 +2470,15 @@ "ui.agent_chat_import_failed": "{name}: import się nie powiódł: {error}", "ui.agent_chat_intro": "Rozmawiaj z agentem programistycznym, którego już używasz, lub z wybranym punktem końcowym modelu. Pracuje on na otwartym projekcie za pomocą narzędzi SolidCraft: jego zmiany są widoczne na bieżąco, a cofa się je jednym krokiem na wiadomość. To, co może robić bez pytania, ustawisz w Preferencje > AI.", "ui.agent_chat_key_in_env": "klucz ustawiony", + "ui.agent_chat_last_missing": "Ostatnio używany agent ({agent}) nie jest już dostępny. Wybierz agenta, a potem naciśnij Ponów.", "ui.agent_chat_left": "Lewa", "ui.agent_chat_limit": "Agent osiągnął limit {limit} zmian na jedną wiadomość. Wyślij kolejną wiadomość, aby mógł kontynuować.", + "ui.agent_chat_login_found": "znaleziono logowanie", "ui.agent_chat_minimize": "Zminimalizuj do paska", "ui.agent_chat_mode_all": "Wszystko", "ui.agent_chat_mode_ask": "Pytaj", "ui.agent_chat_mode_edits": "Edycje", + "ui.agent_chat_model_no_tools": "{model} nie może używać narzędzi SolidCraft, więc może tylko rozmawiać. Wybierz model obsługujący narzędzia.", "ui.agent_chat_model_tip": "Model używany przez agenta. Twój wybór jest zapamiętywany dla tego agenta.", "ui.agent_chat_new": "Nowy czat", "ui.agent_chat_new_confirm": "Rozpocząć nowy czat? Bieżący zostanie wyczyszczony.", @@ -1533,6 +2487,7 @@ "ui.agent_chat_no_control": "Punkt końcowy czatu z agentem się nie uruchomił, więc agent nie ma dostępu do projektu.", "ui.agent_chat_no_images": "Ten agent nie przyjmuje obrazów", "ui.agent_chat_no_picture": "W schowku nie ma obrazu", + "ui.agent_chat_no_tool_call": "Model nie użył narzędzi SolidCraft, więc projekt nie został zmieniony. Wybierz model obsługujący wywoływanie narzędzi; zobacz Modele lokalne w przewodniku czatu z agentem.", "ui.agent_chat_none_found": "Nie znaleziono agenta programistycznego. Zainstaluj jeden z poniższych, a potem naciśnij Odśwież:", "ui.agent_chat_none_found_short": "Nie znaleziono agenta", "ui.agent_chat_not_installed": "Nie zainstalowano", @@ -1542,12 +2497,17 @@ "ui.agent_chat_paste_picture": "Wklej obraz", "ui.agent_chat_permission": "Agent prosi o uruchomienie: {title}", "ui.agent_chat_pick": "Agent", + "ui.agent_chat_pick_context": "Wybierz kontekst", + "ui.agent_chat_pick_context_tip": "Wybierz w modelu ściany, krawędzie, funkcje i bryły, którym agent ma się przyjrzeć, a potem naciśnij OK", "ui.agent_chat_placeholder": "Poproś agenta o zmianę projektu…", "ui.agent_chat_placeholder_busy": "Zakolejkuj wiadomość na czas, gdy agent skończy (Ctrl+Enter: wyślij teraz)", "ui.agent_chat_pop_out": "Otwórz w osobnym oknie, które możesz przenieść na inny ekran", "ui.agent_chat_previous_picture": "Poprzedni obraz", "ui.agent_chat_privacy": "Nic nie trafia nigdzie poza uruchomionym przez Ciebie agentem, na Twoim własnym koncie.", "ui.agent_chat_privacy_note": "Czat sam z siebie nigdzie niczego nie wysyła: uruchomiony przez Ciebie agent łączy się z własną usługą na Twoim koncie. SolidCraft nigdy nie widzi ani nie przechowuje klucza ani hasła.", + "ui.agent_chat_provider_models": "Modele: {count}", + "ui.agent_chat_provider_other": "Inny", + "ui.agent_chat_provider_tip": "Dostawca, którego modele pokazuje następna lista.", "ui.agent_chat_queue": "Kolejka", "ui.agent_chat_queue_full": "Za dużo wiadomości czeka: usuń jedną lub poczekaj na agenta.", "ui.agent_chat_queued": "W kolejce", @@ -1557,6 +2517,7 @@ "ui.agent_chat_remove": "Usuń z kolejki", "ui.agent_chat_remove_file": "Usuń z tej wiadomości", "ui.agent_chat_remove_imported": "Usuń z tej wiadomości; część pozostaje w projekcie (Cofnij ją usuwa)", + "ui.agent_chat_retry": "Ponów", "ui.agent_chat_right": "Prawa", "ui.agent_chat_save": "Zapisz", "ui.agent_chat_send": "Wyślij", @@ -1569,6 +2530,7 @@ "ui.agent_chat_state_auth": "Wymagane logowanie", "ui.agent_chat_state_busy": "Pracuje…", "ui.agent_chat_state_exited": "Agent zatrzymany", + "ui.agent_chat_state_failed": "Połączenie nieudane", "ui.agent_chat_state_off": "Brak połączenia", "ui.agent_chat_state_ready": "Gotowy", "ui.agent_chat_state_starting": "Uruchamianie…", @@ -1576,6 +2538,7 @@ "ui.agent_chat_stop_tip": "Zatrzymaj agenta i wszystko, co uruchomił", "ui.agent_chat_stop_turn_tip": "Zatrzymaj agenta (Esc). Wiadomości w kolejce pozostają w kolejce.", "ui.agent_chat_turn_limit": "Zmiany na wiadomość", + "ui.agent_chat_turn_unlimited": "Bez limitu", "ui.agent_chat_undo_changes": "Cofnij te zmiany", "ui.agent_chat_undo_label": "Czat z agentem: {message}", "ui.agent_chat_undone": "(cofnięto)", @@ -1588,8 +2551,10 @@ "ui.ai_add": "Dodaj", "ui.ai_add_agent": "Dodaj agenta…", "ui.ai_add_agent_title": "Dodaj agenta", + "ui.ai_add_anthropic": "API zgodne z Anthropic", "ui.ai_add_api": "API zgodne z OpenAI", "ui.ai_add_custom": "Polecenie niestandardowe", + "ui.ai_add_endpoint": "Punkt końcowy API", "ui.ai_add_installed": "Zainstalowany agent", "ui.ai_add_installed_note": "Własny wpis dla zainstalowanego agenta, na przykład z innymi argumentami. Zainstalowane agenty są wyświetlane automatycznie.", "ui.ai_added": "dodano", @@ -1619,6 +2584,7 @@ "ui.ai_key_needs_https": "Klucz jest wysyłany tylko przez HTTPS albo do tego komputera.", "ui.ai_key_note": "Przechowywany w systemowym pęku kluczy, nigdy w ustawieniach SolidCraft. Dla serwera lokalnego zostaw puste.", "ui.ai_keychain_error": "Systemowy pęk kluczy odmówił: {error}", + "ui.ai_kind": "Rodzaj", "ui.ai_mode_all": "Zezwalaj na wszystko", "ui.ai_mode_all_note": "Nic nie pyta wcześniej. Raporty o awariach pozostają niedostępne, a ograniczenia folderów nadal obowiązują.", "ui.ai_mode_all_warning": "Agent może zapisywać, nadpisywać, eksportować i zastępować projekt bez pytania. Używaj tego tylko z zaufanym agentem i na zapisanych projektach.", @@ -1640,7 +2606,11 @@ "ui.ai_panel": "Panel", "ui.ai_permissions": "Uprawnienia", "ui.ai_preset": "Ustawienie wstępne", + "ui.ai_protocol": "Protokół", "ui.ai_remove": "Usuń", + "ui.ai_request_timed_out": "Model odpowiadał dłużej niż {seconds} s. Limit czasu żądania dla tego punktu końcowego możesz zwiększyć w Preferencje › Asystent.", + "ui.ai_request_timeout": "Limit czasu żądania", + "ui.ai_request_timeout_hint": "Jak długo może trwać jedno żądanie do modelu w tym punkcie końcowym, od 30 s do godziny. Zwiększ dla wolnego modelu lokalnego.", "ui.ai_reset": "Resetuj", "ui.ai_settings": "Ustawienia AI…", "ui.ai_show_features": "Pokaż funkcje AI", @@ -1649,13 +2619,33 @@ "ui.ai_test": "Testuj", "ui.ai_test_acp_ok": "Odpowiada: {agent}", "ui.ai_test_acp_tip": "Uruchamia na chwilę agenta, prosi go o sesję i ponownie ją kończy", + "ui.ai_test_answered": "Połączono: {model} odpowiedział", + "ui.ai_test_anthropic_tip": "Wysyła krótką wiadomość do punktu końcowego", "ui.ai_test_api_tip": "Prosi punkt końcowy o listę modeli", "ui.ai_test_no_model": "Połączono: modele: {count}, ale brak {model}", "ui.ai_test_ok": "Połączono: modele: {count}", "ui.ai_testing": "Testowanie…", + "ui.ai_tool_arguments": "Argumenty", + "ui.ai_tool_details": "Szczegóły", "ui.ai_tool_done": "gotowe", "ui.ai_tool_failed": "niepowodzenie", + "ui.ai_tool_result": "Wynik", "ui.ai_tool_running": "w toku", + "ui.ai_tool_sum_bodies_one": "Bryły: {count}", + "ui.ai_tool_sum_bodies_other": "Bryły: {count}", + "ui.ai_tool_sum_by": "o {value}", + "ui.ai_tool_sum_edges_one": "Krawędzie: {count}", + "ui.ai_tool_sum_edges_other": "Krawędzie: {count}", + "ui.ai_tool_sum_faces_one": "Ściany: {count}", + "ui.ai_tool_sum_faces_other": "Ściany: {count}", + "ui.ai_tool_sum_features_one": "Funkcje: {count}", + "ui.ai_tool_sum_features_other": "Funkcje: {count}", + "ui.ai_tool_sum_measured": "Zmierzono: {what}", + "ui.ai_tool_sum_more": "{steps} i {count} więcej", + "ui.ai_tool_sum_radius": "r {value}", + "ui.ai_tool_sum_saved": "Zapisano {file}", + "ui.ai_tool_sum_screenshot": "Zrzut ekranu", + "ui.ai_tool_sum_then": "{first}, potem {next}", "ui.ai_tool_unfold": "Kliknij, aby zobaczyć, co zwróciło", "ui.ai_tool_waiting": "oczekuje", "ui.ai_toolbar_button": "Pokaż przycisk na pasku narzędzi", @@ -1685,6 +2675,9 @@ "ui.blocked": "Zablokowane", "ui.bodies": "Bryły", "ui.bodies_all_if_none": "Bryły (wszystkie, jeśli brak)", + "ui.body_kind_mesh": "Bryła siatkowa", + "ui.body_kind_solid": "Bryła pełna", + "ui.body_kind_surface": "Bryła powierzchniowa", "ui.bottom": "Dół", "ui.box": "Prostopadłościan", "ui.break": "Podziel", @@ -1697,6 +2690,8 @@ "ui.cancel": "Anuluj", "ui.canvases": "Płótna", "ui.center": "Środek", + "ui.center_arc_follows_cursor": "Łuk ze środka podąża za kierunkiem kursora", + "ui.center_arc_follows_cursor_tip": "Łuk ze środka biegnie zgodnie lub przeciwnie do ruchu wskazówek zegara, tak jak kursor okrąża jego środek. Wyłączone: zawsze przeciwnie do ruchu wskazówek zegara od początku.", "ui.centerline": "Linia osiowa", "ui.chain": "Łańcuch", "ui.chamfer": "Faza", @@ -1705,6 +2700,7 @@ "ui.circle": "Okrąg", "ui.circular_pattern": "Szyk kołowy", "ui.clear": "Wyczyść", + "ui.clear_print_face": "Usuń powierzchnię druku", "ui.clear_recent": "Wyczyść ostatnie", "ui.clear_the_selection": "Wyczyść zaznaczenie", "ui.click_selects_whole_bodies": "Kliknięcie zaznacza całe bryły", @@ -1739,6 +2735,7 @@ "ui.constant": "Stały", "ui.constraints_conflict": "Konflikt wiązań", "ui.construction": "Konstrukcyjne", + "ui.construction.ribbon_label": "Linie pom.", "ui.context_menu": "Menu kontekstowe", "ui.continuity_g0": "G0 załamanie", "ui.continuity_g1": "G1 styczność", @@ -1747,6 +2744,8 @@ "ui.copy": "Kopiuj", "ui.corner": "Naroże", "ui.corners": "Naroża", + "ui.cosmetic_thread_markings": "Kosmetyczne oznaczenia gwintu", + "ui.cosmetic_thread_markings_help": "Pokazuje schematyczne oznaczenia na gwintowanych ściankach walcowych. Zmienia tylko wyświetlanie, nigdy projekt ani eksport.", "ui.counterbore": "Pogłębienie walcowe", "ui.countersink": "Pogłębienie stożkowe", "ui.create": "Utwórz", @@ -1777,8 +2776,11 @@ "ui.design_properties.part_number": "Numer części", "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Wersja", + "ui.design_saved": "Zapisano {path}", "ui.diameter": "Średnica", "ui.dimension": "Wymiar", + "ui.direct_interaction": "Bezpośrednia praca na modelu", + "ui.direct_interaction_note": "Pracuj na modelu bezpośrednio w widoku. Naciśnij płaską ścianę lub profil i pociągnij: w górę wyciąga, w dół w bryłę wycina, a ruch w bok ustawia wielkość kroku", "ui.direction": "Kierunek", "ui.display_quality": "Jakość wyświetlania", "ui.display_quality_coarse": "Zgrubna", @@ -1800,12 +2802,39 @@ "ui.easyprint_uploading": "Wysyłanie do Printables…", "ui.edit": "Edytuj", "ui.editJointLimits": "Edytuj limity złącza", + "ui.edit_in_place": "Edytuj w miejscu", + "ui.edit_in_place_after_turn": "Edycja zakończy się, gdy agent skończy swoją turę", + "ui.edit_in_place_agent_busy": "Agent wciąż pracuje w tym projekcie; spróbuj ponownie, gdy skończy swoją turę", + "ui.edit_in_place_already_queued": "Jedna edycja już czeka na koniec tury agenta", + "ui.edit_in_place_assembly_closed": "{name} nie jest już otwarty", + "ui.edit_in_place_banner": "Edycja w {name}", + "ui.edit_in_place_busy": "Najpierw zakończ lub anuluj to, co jest otwarte", + "ui.edit_in_place_none": "Żaden połączony projekt nie jest edytowany w miejscu", + "ui.edit_in_place_not_saved": "Część ma niezapisane zmiany; zapisz ją, aby zakończyć edycję", + "ui.edit_in_place_open_elsewhere": "{name} jest otwarty w innym SolidCraft", + "ui.edit_in_place_opens_after_turn": "Część otworzy się, gdy agent skończy swoją turę", + "ui.edit_in_place_refresh_dropped": "Złożenie zmieniło się w trakcie Aktualizuj łącza; uruchom je ponownie", + "ui.edit_in_place_refresh_stopped": "Aktualizuj łącza zatrzymało się przed zakończeniem", + "ui.edit_in_place_refreshing": "Aktualizowanie łączy w {name}…", + "ui.edit_in_place_save_first": "Najpierw zapisz złożenie: jego połączone projekty są wyszukiwane na podstawie jego pliku", + "ui.edit_in_place_save_waits": "W {name} trwa Aktualizuj łącza; zapisz go, gdy się zakończy", + "ui.edit_in_place_saved_elsewhere": "Część zapisano do innego pliku; złożenie nadal ma łącze do {name}", + "ui.edit_in_place_wait": "Poczekaj, aż zakończy się to, co działa w projekcie", "ui.edit_section_analysis": "Edytuj analizę przekroju", "ui.ellipse": "Elipsa", + "ui.emboss_wrap_note": "Ortogonalne profile liniowe styczne do jednego obsługiwanego walca owijają się automatycznie. Wybierz ściankę, aby wskazać cel.", "ui.end_radius": "Promień końcowy", "ui.end_scale": "Skala końcowa", "ui.entities": "Elementy", "ui.equal": "Równość", + "ui.equation_end": "Koniec", + "ui.equation_explicit": "Jawna", + "ui.equation_form": "Postać", + "ui.equation_note": "Zmienna jest zwykłą liczbą: sin(t) przyjmuje ją w radianach. Wynik bez jednostki jest w jednostce projektu.", + "ui.equation_parametric": "Parametryczna", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Promień zastępczy", "ui.expand": "Rozwiń", "ui.exportDxf": "Eksportuj DXF…", @@ -1813,7 +2842,12 @@ "ui.export_keymap_tip": "Klawisze, ulubione z okna S i pasek narzędzi, jako plik", "ui.extend": "Wydłuż", "ui.extent_type": "Typ zakresu", + "ui.extract_component": "Wyodrębnij do pliku…", + "ui.extract_design_changed": "Projekt zmienił się podczas wybierania pliku. Uruchom Wyodrębnij do pliku ponownie.", + "ui.extract_done": "Komponent jest teraz osobnym projektem, połączonym tutaj.", "ui.extrude": "Wyciągnięcie", + "ui.extrude_auto_to_object": "Wyciągaj do wybranego celu", + "ui.extrude_auto_to_object_tip": "W Wyciągnięciu, gdy profil jest wybrany, wybranie ściany, wierzchołka lub równoległej płaszczyzny poza jego płaszczyzną przełącza zakres na Do obiektu", "ui.factor": "Współczynnik", "ui.feature_history": "Historia funkcji", "ui.feature_options": "Opcje funkcji", @@ -1832,10 +2866,12 @@ "ui.flip": "Odwróć", "ui.format": "Format", "ui.frame_rate": "Liczba klatek", + "ui.frame_rate_details": "Szczegóły liczby klatek", "ui.front": "Przód", "ui.fully_constrained": "W pełni związany", "ui.function": "Funkcja", "ui.general": "Ogólne", + "ui.gpu_memory_lowered": "Karcie graficznej zabrakło pamięci: widok jest rysowany w niższej jakości.", "ui.graphics": "Grafika", "ui.graphics_custom": "Własne", "ui.graphics_performance": "Wydajność", @@ -1864,9 +2900,12 @@ "ui.heading_design": "PROJEKT", "ui.heading_draft": "POCHYLENIE", "ui.heading_edit": "EDYCJA", + "ui.heading_equation_curve": "KRZYWA Z RÓWNANIA", "ui.heading_extrude": "WYCIĄGNIĘCIE", "ui.heading_fillet": "ZAOKRĄGLENIE", + "ui.heading_finish_editing": "ZAKOŃCZ EDYCJĘ", "ui.heading_finish_sketch": "ZAKOŃCZ SZKIC", + "ui.heading_form": "FORMA", "ui.heading_front": "PRZÓD", "ui.heading_hole": "OTWÓR", "ui.heading_home": "WIDOK GŁÓWNY", @@ -1923,6 +2962,8 @@ "ui.hideOrigin": "Ukryj początek układu", "ui.hidePlanes": "Ukryj płaszczyzny", "ui.hideProfile": "Ukryj profil", + "ui.hide_origin_by_default": "Domyślnie ukrywaj początek układu", + "ui.hide_origin_note": "Nowe projekty zaczynają z ukrytym folderem Początek układu. Projekt zachowuje własne ustawienie, gdy go pokażesz lub ukryjesz.", "ui.history_back": "Cofnij o jeden krok", "ui.history_below_browser": "Pod przeglądarką", "ui.history_edge_hint": "kliknij dwukrotnie krawędź", @@ -1933,7 +2974,12 @@ "ui.history_marker_tip": "Znacznik historii: przeciągnij, aby cofnąć model; nowe funkcje trafiają tutaj", "ui.history_names": "Nazwy", "ui.history_play": "Odtwórz do końca", + "ui.history_play_button_hide": "Ukryj przycisk Odtwórz", + "ui.history_play_button_show": "Pokaż przycisk Odtwórz", + "ui.history_play_finish_action": "Zakończ bieżące działanie przed odtworzeniem Historii", + "ui.history_play_roll_back": "Przed odtworzeniem przesuń znacznik Historii wstecz", "ui.history_show_names": "Pokaż nazwy", + "ui.history_sketch_listed_under": "Pokazany pod {feature}", "ui.history_title": "Historia", "ui.history_toggle_names": "Pokaż lub ukryj nazwy funkcji", "ui.hole": "Otwór", @@ -1941,14 +2987,21 @@ "ui.hole_type": "Typ otworu", "ui.home": "Widok główny", "ui.homeView": "Widok główny", + "ui.horizontal_constraint": "Wiązanie poziome", "ui.horizontal_vertical": "Poziomo/pionowo", "ui.import_ellipsis": "Importuj…", "ui.infinite": "Nieskończona", "ui.insert": "Wstaw", + "ui.insert_design.browse": "Przeglądaj…", + "ui.insert_design.open_designs": "Otwarte projekty", + "ui.insert_design.saved_file": "{name} ma niezapisane zmiany, więc wstawiono jego zapisany plik. Zapisz go, a potem użyj Aktualizuj łącza, aby wprowadzić zmiany.", + "ui.insert_design.unsaved": "Niezapisane zmiany", "ui.inspect": "Sprawdź", "ui.intersect": "Przytnij", "ui.isolate": "Izoluj", "ui.join": "Połącz", + "ui.joint_play": "▶ Odtwórz", + "ui.joint_stop": "■ Zatrzymaj", "ui.joints": "Złącza", "ui.keyboard_shortcuts": "Skróty klawiszowe", "ui.language": "Język", @@ -1958,10 +3011,15 @@ "ui.length_decimals": "Miejsca dziesiętne długości", "ui.light": "Jasny", "ui.line": "Linia", + "ui.link_copies": "Połącz identyczne kopie", + "ui.link_copies_keep": "Zachowaj", + "ui.link_copies_link": "Połącz", + "ui.link_copies_text": "Te komponenty tworzą te same bryły co {name}. Zamienić je w umiejscowienia jego łącza?", "ui.lock": "Zablokuj", "ui.loft": "Wyciągnięcie złożone", "ui.look_at": "Patrz na", "ui.look_at_anim_desc": "Widok osi, Patrz na, Widok główny i Dopasuj", + "ui.major_radius": "Promień główny", "ui.manipulator_free_alt": "Swobodne przeciąganie manipulatora także z Alt/Option", "ui.manipulator_free_alt_tip": "Wł.: Alt/Option lub Ctrl/Cmd na strzałkach wyciągnięcia / uchwytu wyłącza przyciąganie wartości. Wył.: tylko Ctrl/Cmd.", "ui.manufacture": "Wytwarzanie", @@ -1991,8 +3049,10 @@ "ui.mesh_format_3mf": "3MF", "ui.mesh_format_3mf_tagged": "3MF ze znacznikami ścian", "ui.mesh_format_stl": "STL", + "ui.minor_radius": "Promień mniejszy", "ui.mirror": "Lustro", "ui.mirror_line": "Linia odbicia", + "ui.modifiers_either": "{first} lub {second}", "ui.move": "Przesuń", "ui.multiple": "Wiele", "ui.named_views": "Widoki nazwane", @@ -2019,6 +3079,7 @@ "ui.one_side": "Jedna strona", "ui.open": "Otwórz", "ui.open_ends_tip": "Niczego nie dotyka", + "ui.open_linked_design": "Otwórz połączony projekt", "ui.open_preferences": "Otwórz preferencje", "ui.open_recent": "Otwórz ostatnie", "ui.operation": "Operacja", @@ -2039,6 +3100,11 @@ "ui.paste": "Wklej", "ui.path": "Ścieżka", "ui.pattern_on_path": "Szyk wzdłuż ścieżki", + "ui.perforate_margin_default": "od średnicy", + "ui.perforate_pitch": "Rozstaw", + "ui.perforate_square": "Kwadratowy", + "ui.perforate_staggered": "Przestawny (60°)", + "ui.perforate_title": "PERFORACJA", "ui.perpendicular": "Prostopadłość", "ui.perspective_view": "Widok perspektywiczny", "ui.pinToShortcuts": "Przypnij do skrótów", @@ -2053,21 +3119,49 @@ "ui.plugins": "Wtyczki", "ui.plugins_desktop": "Wtyczki są dostępne w aplikacji desktopowej.", "ui.plugins_empty": "Brak zainstalowanych wtyczek.", + "ui.plugins_none": "Żadna wtyczka nie pasuje do wyszukiwania.", "ui.plugins_note": "Każda wtyczka to folder w {path} z plikiem plugin.json. SolidCraft odczytuje ten plik i nie uruchamia żadnego kodu z folderu. Wyłączenie wtyczki nie odinstalowuje jej.", + "ui.plugins_search": "Szukaj wtyczek", "ui.plugins_skipped": "Nie udało się odczytać folderów: {count}.", "ui.plugins_version": "{version}", "ui.point": "Punkt", + "ui.point_cloud_auto_range": "Zakres automatyczny", + "ui.point_cloud_color_by": "Koloruj według", + "ui.point_cloud_file_colors": "Kolory z pliku", + "ui.point_cloud_height_high": "Najwyżej", + "ui.point_cloud_height_low": "Najniżej", + "ui.point_cloud_no_file_colors": "Plik nie zawiera kolorów", + "ui.point_cloud_single_color": "Jeden kolor", + "ui.point_cloud_size": "Rozmiar punktu", + "ui.point_cloud_units": "Jednostki pliku", "ui.point_under_the_cursor": "Punkt pod kursorem", "ui.points": "Punkty", "ui.polygon": "Wielokąt", "ui.preferences": "Preferencje", "ui.press_a_key": "naciśnij klawisz…", + "ui.print_face_cleared": "{body}: usunięto powierzchnię druku", + "ui.print_face_input": "Powierzchnia druku", + "ui.print_face_one_body": "Powierzchnia druku należy do jednej bryły: wyślij tę bryłę osobno, aby ją zmienić", + "ui.print_face_pick": "Wybierz płaską powierzchnię bryły, aby ustawić ją jako powierzchnię druku", + "ui.print_face_set": "{body}: ustawiono powierzchnię druku; Wyślij do slicera kładzie bryłę na niej", "ui.profile": "Profil", "ui.profile_plane": "Płaszczyzna profilu", "ui.profiles": "Profile", "ui.project": "Rzutuj", "ui.project_face_edges_sketch": "Rzutuj krawędzie ściany do nowych szkiców", "ui.properties": "Właściwości", + "ui.properties.copy_matrix": "Kopiuj macierz", + "ui.properties.first_moments": "Momenty statyczne", + "ui.properties.inertia": "Moment bezwładności", + "ui.properties.inertia_note": "Względem środka masy, w osiach globalnych (g·{unit}²)", + "ui.properties.inertia_unavailable": "Niedostępne: siatka bryły nie jest zamknięta.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Pomiar…", + "ui.properties.measuring_failed": "Pomiar zatrzymał się przed zakończeniem.", + "ui.properties.principal_axis": "Oś główna {number}", + "ui.properties.principal_moments": "Główne momenty bezwładności", "ui.pull_direction": "Kierunek wyjmowania", "ui.quality": "Jakość", "ui.quantity": "Liczba", @@ -2081,6 +3175,9 @@ "ui.rectangle": "Prostokąt", "ui.rectangular_pattern": "Szyk prostokątny", "ui.redo": "Ponów", + "ui.reference_1": "Odniesienie 1", + "ui.reference_2": "Odniesienie 2", + "ui.reference_distance": "Odległość odniesienia", "ui.relink_design_changed": "Zespół zmienił się podczas wyboru źródła. Uruchom ponownie Połącz ponownie ze źródłem.", "ui.relink_done": "Ponownie połączono źródło dla wszystkich wystąpień.", "ui.relink_not_linked": "Ten komponent nie jest połączony z projektem źródłowym.", @@ -2091,6 +3188,8 @@ "ui.render": "Renderowanie", "ui.renderer": "Renderer", "ui.repeat": "Powtórz", + "ui.repeat_nothing": "Nie ma jeszcze czego powtórzyć: najpierw uruchom narzędzie lub okno.", + "ui.replace_with_link_done": "Kopie są teraz umiejscowieniami połączonego projektu.", "ui.report_a_bug": "Zgłoś błąd", "ui.reset": "Resetuj", "ui.reset_all": "Resetuj wszystko", @@ -2099,7 +3198,12 @@ "ui.restore_defaults": "Przywróć domyślne", "ui.reverse_horizontal_pan_direction": "Odwróć kierunek przesuwania w poziomie", "ui.reverse_horizontal_pan_tip": "Przewijanie w poziomie dwoma palcami i kółko kciukowe przesuwają w przeciwną stronę", + "ui.reverse_trackpad_orbit_direction": "Odwróć kierunek obracania gładzikiem", + "ui.reverse_trackpad_orbit_tip": "Shift z przewijaniem dwoma palcami obraca widok w przeciwną stronę", + "ui.reverse_vertical_pan_direction": "Odwróć kierunek przesuwania w pionie", + "ui.reverse_vertical_pan_tip": "Przewijanie w pionie dwoma palcami przesuwa w przeciwną stronę", "ui.reverse_zoom_direction": "Odwróć kierunek powiększania", + "ui.revolutions": "Obroty", "ui.revolve": "Obrót", "ui.ribbon_exit": "Wyjdź", "ui.ribbon_labels": "Pokazuj tekst pod ikonami wstążki", @@ -2108,6 +3212,7 @@ "ui.right": "Prawy", "ui.save": "Zapisz", "ui.save_as": "Zapisz jako", + "ui.save_mesh_no_slicer_open": "Nie znaleziono slicera: wybierz Niestandardowy… i wskaż program slicera", "ui.save_mesh_pick_bodies": "Zaznacz bryły do zapisania", "ui.saved_to": "Zapisano {path}", "ui.sbox_pin": "Przypnij na górze okna S", @@ -2115,6 +3220,7 @@ "ui.scale": "Skala", "ui.second_rail": "Druga prowadnica", "ui.section_analysis": "Analiza przekroju", + "ui.section_size": "Rozmiar przekroju", "ui.select": "Zaznacz", "ui.selectOther": "Wybierz inny…", "ui.selectTangentChain": "Zaznacz łańcuch styczny", @@ -2124,9 +3230,19 @@ "ui.selection_shift_add_only": "Shift+kliknięcie tylko dodaje", "ui.selection_shift_add_only_tip": "Wł.: Shift dodaje elementy, Ctrl/Cmd przełącza. Wył. (domyślnie): Shift i Ctrl/Cmd przełączają.", "ui.send": "Wyślij", + "ui.send_to_slicer_unavailable": "Wyślij do slicera wymaga aplikacji desktopowej: przeglądarka nie może uruchomić programu", "ui.sent_to_slicer": "Wysłano do {slicer}", + "ui.sent_to_slicer_copy": "Wysłano do {slicer} jako {file}, ponieważ pierwszy plik jest tam nadal otwarty", "ui.sheet_gap": "Szczelina", "ui.shell": "Skorupa", + "ui.shortcut_capture_add_key": "Dodaj jeden zwykły klawisz. Escape anuluje.", + "ui.shortcut_capture_hint": "Kliknij przypisanie, a potem naciśnij modyfikatory i jeden klawisz. Modyfikatory dodaj w ciągu sekundy; puść wszystkie klawisze, aby potwierdzić. Escape anuluje.", + "ui.shortcut_capture_press": "Naciśnij modyfikatory i jeden klawisz. Escape anuluje.", + "ui.shortcut_capture_release": "Puść wszystkie klawisze, aby potwierdzić. Escape anuluje.", + "ui.shortcut_capture_settling": "Potwierdzanie skrótu… Escape anuluje.", + "ui.shortcut_cycles_modes": "Ponowne naciśnięcie skrótu narzędzia przełącza jego tryb", + "ui.shortcut_cycles_modes_tip": "Przed pierwszym punktem ponowne naciśnięcie skrótu narzędzia szkicu przełącza kolejno jego tryby tworzenia. Wyłączone: uruchamia narzędzie od nowa.", + "ui.shortcut_multiple_keys": "Użyj modyfikatorów i tylko jednego innego klawisza. Kliknij przypisanie, aby spróbować ponownie.", "ui.show": "Pokaż", "ui.showAll": "Pokaż wszystko", "ui.showAllBodies": "Pokaż wszystkie bryły", @@ -2145,12 +3261,25 @@ "ui.sketch": "Szkic", "ui.sketch_dimension": "Wymiar szkicu", "ui.sketch_grid": "Siatka szkicu", + "ui.sketch_intersect_geometry": "Geometria", + "ui.sketch_intersect_projection_link": "Powiązanie rzutu", + "ui.sketch_intersect_selection_filter": "Filtr zaznaczania", "ui.sketch_modifiers": "Modyfikatory szkicu", + "ui.sketch_project_geometry": "Geometria", "ui.sketch_scale": "Skala szkicu", "ui.sketches": "Szkice", "ui.slice": "Przetnij", "ui.slicer": "Slicer", "ui.slicer_delete_tip": "{program}\nNaciśnij Delete, aby usunąć go z listy", + "ui.slicer_lost": "{slicer} został uruchomiony, ale nie można go ponownie znaleźć, aby wysłać plik. Plik jest zapisany w {path}: otwórz go stamtąd.", + "ui.slicer_no_file": "{slicer} jest gotowy, ale nie ma pliku do wysłania.", + "ui.slicer_no_response": "{slicer} nie odpowiedział na czas. Plik jest już zapisany w {path}: wyślij go ponownie, gdy slicer się uruchomi.", + "ui.slicer_resend_hint": "Ponowne wysłanie zastępuje plik otwarty w slicerze.", + "ui.slicer_single_instance": "Otwieraj pliki w już uruchomionym slicerze", + "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer i Cura wczytują plik w otwartym oknie, zamiast uruchamiać kolejne. Inne slicery są uruchamiane normalnie. PrusaSlicer 3.0 zawsze tak robi: jego program uruchamiający nie przyjmuje pliku w inny sposób.", + "ui.slicer_starting": "Uruchamianie {slicer}…", + "ui.slicer_still_starting": "Nadal czekam na uruchomienie {slicer}. Nic więcej nie wysłano.", + "ui.slicer_stl_fallback": "Nie udało się zapisać 3MF, więc to jest STL.", "ui.slot": "Rowek", "ui.smooth": "Gładkie", "ui.smooth_wheel_zoom": "Płynne powiększanie kółkiem", @@ -2166,6 +3295,9 @@ "ui.start": "Start", "ui.start_over": "Zacznij od nowa", "ui.start_page": "Strona startowa", + "ui.step_values": "Zmieniaj wartości klawiszami strzałek i kółkiem", + "ui.step_values_tip": "W polu wartości, w którym piszesz, strzałki w górę i w dół zmieniają zwykłą liczbę o 1 (Shift: 10, Alt: 0,1), podobnie kółko myszy nad polem. Wyrażenia pozostają tak, jak je wpisano.", + "ui.stitch_tolerance_hint": "ciasna", "ui.stretch": "Rozciągnij", "ui.supersampling": "Supersampling (SSAA)", "ui.supersampling_adaptive": "Tylko gdy widok jest nieruchomy", @@ -2184,6 +3316,7 @@ "ui.tab_manage": "Zarządzanie", "ui.tab_sheet_metal": "Blacha", "ui.tab_view": "Widok", + "ui.tab_weldments": "Konstrukcje spawane", "ui.tangent": "Styczność", "ui.taper_angle": "Kąt pochylenia", "ui.tapped": "Gwintowany", @@ -2199,11 +3332,15 @@ "ui.tip_angle": "Kąt wierzchołkowy", "ui.to_object": "Do obiektu", "ui.tool_direction": "Kierunek narzędzia", + "ui.toolbar_tabs_hidden": "Wszystkie karty paska narzędzi są ukryte. Kliknij + lub kliknij tutaj prawym przyciskiem, aby pokazać jedną.", "ui.top": "Góra", + "ui.top_diameter": "Średnica górna", + "ui.top_radius": "Promień górny", "ui.torus": "Torus", "ui.total_angle": "Kąt całkowity", "ui.trackpad_pan_tip": "Gesty na gładziku przesuwają (wyłączone: powiększają jak kółko); kółko myszy powiększa, kółko kciukowe przesuwa w bok, szczypanie zawsze powiększa", "ui.trim": "Przytnij", + "ui.turn_up_axis": "Obróć oś pionową", "ui.twist": "Skręcenie", "ui.two_finger_scroll_pans": "Przewijanie dwoma palcami przesuwa", "ui.two_sides": "Dwie strony", @@ -2220,12 +3357,34 @@ "ui.up_axis_note": "Która oś wskazuje górę w widokach (widok osi, Widok główny, Patrz na). Projekty pozostają bez zmian.", "ui.updateToCurrentView": "Aktualizuj do bieżącego widoku", "ui.upload": "Wyślij", + "ui.vertical_constraint": "Wiązanie pionowe", + "ui.view_angles": "Kąty widoku", + "ui.view_angles_convention": "Stopnie wokół osi pionowej. Odchylenie 0° to widok z przodu, 90° z lewej, −90° z prawej, 180° z tyłu; pochylenie 90° to widok z góry; dodatnie przechylenie obraca model zgodnie z ruchem wskazówek zegara.", + "ui.view_angles_help": "Pokazuje pochylenie, odchylenie i przechylenie widoku w stopniach oraz jego rzutowanie pod wskaźnikiem orientacji. Kliknij odczyt, aby wpisać dokładne kąty.", + "ui.view_angles_ortho": "Orto", + "ui.view_angles_persp": "Persp", + "ui.view_angles_pitch": "Pochylenie", + "ui.view_angles_readout": "Pochylenie {pitch} · Odchylenie {yaw} · Przechylenie {roll}", + "ui.view_angles_roll": "Przechylenie", + "ui.view_angles_yaw": "Odchylenie", "ui.view_centre": "Środek widoku", + "ui.viewport_status_clear": "Wyłącz {name}", + "ui.viewport_status_clear_hint": "Wyłącz {name} · Esc", + "ui.viewport_status_key": "Pokaż legendę dla {name}", + "ui.viewport_status_minimum_radius": "Promień minimalny", + "ui.viewport_status_pairs": "Pary: {count}", + "ui.viewport_status_stripes": "Pasy: {count}", + "ui.viewport_theme_follow": "Zgodnie z motywem", "ui.visual_shaded_hidden_edges": "Cieniowany z krawędziami niewidocznymi", "ui.visual_shaded_with_edges": "Cieniowany z krawędziami", "ui.visual_style": "Styl wizualny", "ui.visual_wireframe": "Model krawędziowy", + "ui.wheel_zoom_speed": "Szybkość powiększania kółkiem myszy", "ui.width": "Szerokość", + "ui.window_system": "System okien", + "ui.window_system_auto": "Automatycznie", + "ui.window_system_wayland": "Wayland", + "ui.window_system_x11": "X11", "ui.workspace": "środowisko pracy", "ui.workspace_animation_description": "Widoki rozstrzelone, badania ruchu i scenorysy", "ui.workspace_available": "Dostępne", @@ -2251,7 +3410,9 @@ "update.button": "Aktualizuj", "update.button_version": "Aktualizuj do {version}", "update.check_automatically": "Automatycznie sprawdzaj aktualizacje", + "update.check_for_updates": "Sprawdź aktualizacje", "update.check_now": "Sprawdź teraz", + "update.check_unavailable": "Sprawdzanie aktualizacji nie jest dostępne dla tej instalacji.", "update.checking": "Sprawdzanie…", "update.copy": "Kopiuj", "update.dmg_opened": "Obraz dysku jest otwarty i zweryfikowany: przeciągnij SolidCraft do folderu Aplikacje, zastępując starą kopię, a następnie uruchom SolidCraft ponownie.", @@ -2265,12 +3426,18 @@ "update.installed": "SolidCraft {version} jest zainstalowany. Uruchom ponownie, aby go używać.", "update.installer_started": "Instalator uruchomi się po zamknięciu SolidCraft.", "update.later": "Przypomnij później", + "update.managed": "Dostępna aktualizacja: zaktualizuj SolidCraft w {manager}", "update.no_notes": "To wydanie nie ma opisu zmian.", "update.package_hint": "Ta kopia pochodzi z {kind}. Pobierz nowy pakiet, a następnie zainstaluj go poleceniem:", "update.prefs_dev": "To jest wersja deweloperska ({version}): nie sprawdza aktualizacji.", "update.prefs_flatpak": "Flatpak aktualizuje tę kopię ({version}): uruchom {command} lub użyj centrum oprogramowania. SolidCraft nie sprawdza aktualizacji.", "update.prefs_label": "Aktualizacje", + "update.prefs_managed": "To jest SolidCraft {version}, zarządzany przez {manager}. Sprawdzanie pyta GitHuba o najnowsze wydanie i nie wysyła nic więcej; zaktualizuj SolidCraft w {manager}.", "update.prefs_note": "To jest SolidCraft {version} ({kind}). Sprawdzanie pyta GitHuba o najnowsze wydanie i nie wysyła nic więcej.", + "update.prefs_web": "Odśwież stronę, aby pobrać najnowszą aplikację internetową. Jeśli sieć jest niedostępna lub aktualizacja nie zdąży się pobrać, otworzy się zapisana wersja; trwające pobieranie będzie kontynuowane przy następnym odświeżeniu.", + "update.refresh_checking": "Sprawdzanie najnowszego wydania…", + "update.refresh_fallback": "Nie udało się sprawdzić nowszego wydania; aktualizacja do wcześniej znalezionej wersji {version}.", + "update.refresh_selected": "Aktualizacja do wersji {version}.", "update.release_page": "Otwórz stronę wydania", "update.restart": "Uruchom ponownie teraz", "update.restart_later": "Później", @@ -2281,5 +3448,17 @@ "update.up_to_date": "SolidCraft jest aktualny.", "update.update_now": "Aktualizuj teraz", "update.you_have": "Masz wersję {version}.", - "↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ wybierz · Enter / Tab uzupełnij · Esc zamknij" + "use a positive nominal diameter and a pitch smaller than it": "użyj dodatniej średnicy nominalnej i mniejszego od niej skoku", + "values must be finite numbers": "wartości muszą być skończonymi liczbami", + "values must be numbers or expressions": "wartości muszą być liczbami lub wyrażeniami", + "view.auto_ortho_faces": "Perspektywa podczas obracania, widok ortogonalny na ścianach", + "view.render_inspector": "Inspektor renderowania", + "web.files.insert_drawings_hint": "Pliki CAD, siatki, DXF lub SVG do otwartego projektu", + "web.files.insert_stored_drawings_hint": "Do otwartego projektu (pliki CAD, siatki, DXF, SVG)", + "web.open_full": "Otwórz w SolidCraft", + "web.open_link": "Otworzyć połączony projekt?", + "web.open_link_confirm": "Są niezapisane zmiany. Otworzyć połączony projekt w nowej karcie?", + "↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ wybierz · Enter / Tab uzupełnij · Esc zamknij", + "↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ wybierz · Enter stosuje to, co wpisano · Esc zamyka", + "⌘Enter continues on GitHub.": "⌘Enter przechodzi dalej na GitHubie." } diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json index 3e0fc78eb5..51b4c84fa1 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.body.print_face": "Face de impressão", "cmd.body_rename": "Renomear corpo", "cmd.browser_group": "Novo grupo", "cmd.browser_move": "Mover itens do navegador", @@ -815,6 +816,16 @@ "cmd.sheet_refold": "Redobrar", "cmd.sheet_rules": "Listar regras de chapa metálica", "cmd.sheet_unfold": "Desdobrar", + "cmd.sketch.construction_line": "Reta de construção", + "cmd.sketch.construction_line.angle": "Reta de construção em ângulo", + "cmd.sketch.construction_line.bisector": "Bissetriz", + "cmd.sketch.construction_line.horizontal": "Reta de construção horizontal", + "cmd.sketch.construction_line.parallel": "Reta de construção paralela", + "cmd.sketch.construction_line.perpendicular": "Reta de construção perpendicular", + "cmd.sketch.construction_line.tangent_at": "Tangente em um ponto da curva", + "cmd.sketch.construction_line.tangent_external": "Tangente por um ponto externo", + "cmd.sketch.construction_line.tangent_two": "Tangente comum", + "cmd.sketch.construction_line.vertical": "Reta de construção vertical", "cmd.sketch_arc_center_point": "Arco por ponto central", "cmd.sketch_arc_tangent": "Arco tangente", "cmd.sketch_arc_three_point": "Arco por 3 pontos", @@ -1282,6 +1293,8 @@ "prefs.orientation": "Orientação", "prefs.overall_speed": "Velocidade geral", "prefs.preview_choices": "Prévia das suas escolhas", + "prefs.print_face_highlight": "Destaque da face de impressão", + "prefs.print_face_highlight_tip": "Intensidade da cor e do contorno da face de impressão de um corpo na vista. 0 % não mostra destaque.", "prefs.quality_default": "padrão", "prefs.quality_fastest": "mais rápido", "prefs.quality_slowest": "mais lento", @@ -1561,6 +1574,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.", "ui.3d_mouse_switch_cap": "Calibrar como retorno ao centro", "ui.3d_mouse_switch_trackball": "Calibrar como trackball", + "ui.3d_mouse_use_direct": "Usar driver direto", + "ui.3d_mouse_use_direct_tip": "Ler o mouse 3D diretamente (hidraw) em vez de pelo spacenavd.", "ui.3d_mouse_view_axis": "Eixo da vista", "ui.3d_mouse_view_off": "Desligado", "ui.3d_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.", @@ -1830,6 +1845,7 @@ "ui.circle": "Círculo", "ui.circular_pattern": "Padrão circular", "ui.clear": "Limpar", + "ui.clear_print_face": "Limpar face de impressão", "ui.clear_recent": "Limpar recentes", "ui.clear_the_selection": "Limpar a seleção", "ui.click_selects_whole_bodies": "O clique seleciona corpos inteiros", @@ -1864,6 +1880,7 @@ "ui.constant": "Constante", "ui.constraints_conflict": "Restrições em conflito", "ui.construction": "Construção", + "ui.construction.ribbon_label": "Linhas aux.", "ui.context_menu": "Menu de contexto", "ui.continuity_g0": "G0 vinco", "ui.continuity_g1": "G1 tangente", @@ -2187,6 +2204,11 @@ "ui.polygon": "Polígono", "ui.preferences": "Preferências", "ui.press_a_key": "pressione uma tecla…", + "ui.print_face_cleared": "{body}: face de impressão removida", + "ui.print_face_input": "Face de impressão", + "ui.print_face_one_body": "Uma face de impressão pertence a um único corpo: envie esse corpo sozinho para alterá-la", + "ui.print_face_pick": "Selecione uma face plana de um corpo sólido para torná-la a face de impressão", + "ui.print_face_set": "{body}: face de impressão definida; Enviar ao fatiador apoia o corpo sobre ela", "ui.profile": "Perfil", "ui.profile_plane": "Plano do perfil", "ui.profiles": "Perfis", @@ -2377,7 +2399,9 @@ "update.button": "Atualizar", "update.button_version": "Atualizar para {version}", "update.check_automatically": "Verificar atualizações automaticamente", + "update.check_for_updates": "Verificar atualizações", "update.check_now": "Verificar agora", + "update.check_unavailable": "A verificação de atualizações não está disponível para esta instalação.", "update.checking": "Verificando…", "update.copy": "Copiar", "update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o SolidCraft para Aplicativos, substituindo a cópia antiga, e reinicie o SolidCraft.", diff --git a/assets/locales/ro.json b/assets/locales/ro.json index e87f7761d5..3ba059087f 100644 --- a/assets/locales/ro.json +++ b/assets/locales/ro.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Proiectele deschise sau salvate apar aici.", "Determined by To Object. Choose Angle to edit the angular extent.": "Determinat de Până la obiect. Alegeți Unghi pentru a edita întinderea unghiulară.", "Diameter": "Diametru", + "Did this assembly open the wrong way up?": "Acest ansamblu s-a deschis cu orientarea greșită?", + "Did this part open the wrong way up?": "Această piesă s-a deschis cu orientarea greșită?", "Dimension new shapes": "Cotează formele noi", "Direction": "Direcție", "Discard": "Renunță", @@ -204,6 +206,7 @@ "Edges": "Muchii", "Edit": "Editează", "Edit Profile Sketch": "Editează schița profilului", + "Edit shortcuts": "Editează comenzile rapide", "Enable rule": "Activează regula", "Enabled": "Activat", "End Radius": "Rază finală", @@ -466,6 +469,7 @@ "Radius": "Rază", "Radius Fit": "Potrivire rază", "Ratio": "Raport", + "Read-only list: shortcuts can't be changed here.": "Listă doar pentru citire: comenzile rapide nu pot fi modificate aici.", "Reattach selected face": "Reatașează fața selectată", "Recover": "Recuperează", "Recover unsaved design?": "Recuperați proiectul nesalvat?", @@ -599,6 +603,7 @@ "Square": "Pătrat", "Stand it up": "Ridică-l în picioare", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Ridică-l în picioare: un sfert de rotație în jurul lui X, ca o singură operație de mutare ce poate fi anulată.", + "Stand this assembly up?": "Ridicați acest ansamblu în picioare?", "Stand this part up?": "Ridicați această piesă în picioare?", "Start angle": "Unghi de început", "Start bulge": "Bombare inițială", @@ -649,6 +654,7 @@ "Third point": "Al treilea punct", "This child is shared. Deleting it removes it from every copy of its parent.": "Acest element copil este partajat. Ștergerea lui îl elimină din fiecare copie a părintelui.", "This child is shared. Position changes apply to every copy of its parent.": "Acest element copil este partajat. Modificările de poziție se aplică fiecărei copii a părintelui.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Această componentă se află într-un subansamblu plasat de mai multe ori: alinierea ei o mută în fiecare plasare.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Această față nu poate oferi o atașare durabilă. Alegeți altă față sau folosiți explicit un plan fix.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Această imagine nu este încă atașată la GitHub. Copiați-o sau salvați-o, apoi atașați-o pe GitHub.", "Thread depth": "Adâncime filet", @@ -769,6 +775,8 @@ "at the face": "la față", "bom.group_children": "Subpiesele în rândul piesei lor", "browser.body_name": "Corp{number}", + "browser.calibrate_click_a": "Faceți clic pe imagine pentru Punctul A sau tastați coordonatele acestuia.", + "browser.calibrate_click_b": "Faceți clic pe imagine pentru Punctul B sau tastați coordonatele acestuia.", "browser.calibrate_title": "Calibrare: {name}", "browser.capture_many": "{count} componente au fost mutate, dar pozițiile lor nu sunt capturate.", "browser.capture_note": "Captura păstrează noua poziție; Revenire o readuce.", @@ -790,6 +798,11 @@ "cam.roller": "Rolă", "cam.roller_pick": "Rolă (opțional)", "cam.tip_pick": "Vârf (opțional)", + "camera.shot_pitch": "Înclinare", + "camera.shot_roll": "Ruliu", + "camera.shot_yaw": "Girație", + "canvas.decal_place_hint": "Faceți clic pe o față pentru a plasa autocolantul", + "canvas.place_hint": "Faceți clic pe un plan sau pe o față pentru a plasa pânza", "choose a profile": "alegeți un profil", "choose the cam's and the follower's joints": "alegeți îmbinările camei și ale tachetului", "choose the joints to couple": "alegeți îmbinările de cuplat", @@ -797,12 +810,16 @@ "click sketch curves": "faceți clic pe curbele schiței", "cmd.appearance_assign": "Aspect", "cmd.appearance_chain": "Lanț de aspecte", + "cmd.appearance_copy": "Copiază aspectul", + "cmd.appearance_favorite": "Aspect favorit", "cmd.appearance_library": "Bibliotecă de aspecte", "cmd.appearance_list": "Listează aspectele", "cmd.appearance_paint": "Pictează aspectul", + "cmd.appearance_paste": "Lipește aspectul", "cmd.appearance_pick_up": "Preia aspectul", "cmd.appearance_replace": "Înlocuiește aspectul", "cmd.assembly_bom": "Listă de materiale", + "cmd.body.print_face": "Față de imprimare", "cmd.body_rename": "Redenumește corpul", "cmd.browser_collapse_all": "Restrânge tot", "cmd.browser_expand_all": "Extinde tot", @@ -810,6 +827,7 @@ "cmd.browser_move": "Mută elementele din navigator", "cmd.browser_order": "Ordonează elementele din navigator", "cmd.browser_rename_group": "Redenumește grupul", + "cmd.browser_reparent": "Mută în componentă", "cmd.browser_ungroup": "Desfă grupul", "cmd.browser_visibility": "Afișează/Ascunde", "cmd.canvas_calibrate": "Calibrează pânza", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Proprietățile piesei", "cmd.component_metadata_set": "Modifică proprietățile piesei", "cmd.component_move_bodies": "Mută corpurile în componentă", + "cmd.component_move_features": "Mută operațiile în componentă", "cmd.component_move_sketches": "Mută schițele în componentă", + "cmd.component_paste_from_design": "Lipește din alt proiect", "cmd.component_paste_new": "Lipește ca nou", "cmd.component_properties": "Proprietățile fizice ale componentei", "cmd.component_relink": "Reface legătura sursei", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Urma mișcării", "cmd.motion_trace_edit": "Editează urma mișcării", "cmd.motion_trace_keep": "Păstrează urma mișcării", + "cmd.occurrence_align": "Aliniază apariția", "cmd.occurrence_copy": "Lipește (instanță)", "cmd.occurrence_drag": "Trage apariția", "cmd.occurrence_ground": "Fixează", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Elimină asocierea suprafeței", "cmd.render_library_targets": "Găsește țintele bibliotecii de randare", "cmd.render_lights_add": "Adaugă lumină", + "cmd.render_lights_duplicate": "Duplică lumina de randare", + "cmd.render_lights_list": "Listează luminile de randare", + "cmd.render_lights_mute": "Dezactivează lumina", + "cmd.render_lights_remove": "Elimină lumina de randare", + "cmd.render_lights_set": "Setează lumina de randare", "cmd.render_lights_solo": "Lumină solo", "cmd.render_optics_bind": "Salvează asocierea optică", "cmd.render_optics_unbind": "Elimină asocierea optică", "cmd.render_output_size": "Setează dimensiunea ieșirii de randare", "cmd.render_preflight": "Verifică cererea de randare", + "cmd.render_preflight_copy": "Copiază raportul de verificare a randării", "cmd.render_preset_apply": "Aplică Studio nativ", "cmd.render_preset_resolve": "Verifică Studio nativ", "cmd.render_quality_prepare": "Verifică bugetul fix de eșantionare", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Randează imaginea", "cmd.render_status": "Progresul randării", "cmd.select_clear": "Șterge selecția", + "cmd.settings_profile_apply": "Aplică profilul de setări", + "cmd.settings_profile_delete": "Șterge profilul de setări", + "cmd.settings_profile_export": "Exportă profilul de setări", + "cmd.settings_profile_import": "Importă profilul de setări", + "cmd.settings_profile_list": "Profiluri de setări", + "cmd.settings_profile_rename": "Redenumește profilul de setări", + "cmd.settings_profile_save": "Salvează profilul de setări", "cmd.sheet_convert": "Convertește în tablă", "cmd.sheet_export_bend_table": "Exportă tabelul de îndoire", "cmd.sheet_export_dxf": "Exportă desfășurata ca DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Reîndoire", "cmd.sheet_rules": "Listează regulile pentru tablă", "cmd.sheet_unfold": "Desfășurare", + "cmd.sketch.construction_line": "Dreaptă de construcție", + "cmd.sketch.construction_line.angle": "Dreaptă de construcție la un unghi", + "cmd.sketch.construction_line.bisector": "Bisectoare", + "cmd.sketch.construction_line.horizontal": "Dreaptă de construcție orizontală", + "cmd.sketch.construction_line.parallel": "Dreaptă de construcție paralelă", + "cmd.sketch.construction_line.perpendicular": "Dreaptă de construcție perpendiculară", + "cmd.sketch.construction_line.tangent_at": "Tangentă într-un punct al curbei", + "cmd.sketch.construction_line.tangent_external": "Tangentă printr-un punct exterior", + "cmd.sketch.construction_line.tangent_two": "Tangentă comună", + "cmd.sketch.construction_line.vertical": "Dreaptă de construcție verticală", "cmd.sketch_arc3d": "Arc 3D", "cmd.sketch_arc_center_point": "Arc după centru", "cmd.sketch_arc_tangent": "Arc tangent", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Dimensiunea desenului", "cmd.sketch_edit": "Editează schița", "cmd.sketch_edit_text": "Editează textul", + "cmd.sketch_equation_curve": "Curbă din ecuație", + "cmd.sketch_equation_curve_edit": "Editează curba din ecuație", "cmd.sketch_export_dxf": "Salvează ca DXF", "cmd.sketch_finish_auto_constrain": "Finalizează cu constrângere automată", "cmd.sketch_fit_curves_to_section": "Potrivește curbe la secțiunea plasei", @@ -1198,6 +1244,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_items": "Elemente de stare ale ferestrei de vizualizare", "cmd.view_ambient_occlusion": "Ocluzie ambientală", "cmd.view_angle_readout": "Unghiuri de vizualizare", "cmd.view_apply": "Restabilește vizualizarea numită", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Normal pe selecție", "cmd.view_previous_tab": "Fila anterioară", "cmd.view_rename": "Redenumește vizualizarea numită", + "cmd.view_render_frame": "Ghiduri de încadrare pentru randare", "cmd.view_right": "Vizualizare din dreapta", "cmd.view_save": "Vizualizare numită nouă", "cmd.view_shots_strip": "Bandă de cadre", @@ -1229,10 +1277,14 @@ "color.copy": "Clic pentru a copia valorile culorii", "color.edit": "Clic pentru a edita culoarea", "color.gamma": "Se afișează valorile culorii în spațiul gamma 0-255", + "color.hex": "Hex", + "color.hex_help": "Introduceți #rrggbb sau #rrggbbaa; cu șase cifre se păstrează opacitatea", + "color.hex_hint": "#rrggbb", "color.hue": "Nuanță", "color.linear": "Se afișează valorile culorii în spațiul liniar 0-1", "color.saturation_value": "Saturație și valoare", "color.selected": "Culoare selectată", + "config.column_filter_empty": "Nicio coloană potrivită", "contact.components_count": "{count} componente", "contact.enabled_label": "activat", "coupling.gear_1": "Roată dințată 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Faceți clic pe altă cotă de comandă pentru a-i insera numele", "dialog.dimension_name": "Cotă: {name}", "dialog.edit_title": "EDITARE {title}", + "dialog.no_shown_body": "Niciun corp afișat de modificat: afișați unul sau alegeți Corp nou", "dialog.select": "↖ Selectează", "dialog.selected_count": "↖ {count} selectate", "dialog.selection_guidance": "Selectați o intrare pentru a o modifica. Faceți clic pe obiectele selectate pentru a le elimina; Ctrl/Cmd-clic le comută de asemenea. Verificați previzualizarea, apoi apăsați OK.", @@ -1346,8 +1399,20 @@ "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.mit": "Licența MIT", "help.notice": "NOTICE", + "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.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.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ă.", + "help.sketch.construction_line.horizontal": "Creați o dreaptă de construcție orizontală infinită printr-un punct. Constrângerea orizontală rămâne valabilă la editările ulterioare.", + "help.sketch.construction_line.parallel": "Alegeți o linie dreaptă a schiței, apoi plasați o dreaptă de construcție paralelă infinită. Clic dreapta pentru a alege o altă referință.", + "help.sketch.construction_line.perpendicular": "Alegeți o linie dreaptă a schiței, apoi plasați o dreaptă de construcție perpendiculară infinită. Clic dreapta pentru a alege o altă referință.", + "help.sketch.construction_line.tangent_at": "Creați o tangentă într-un punct al unui cerc sau arc de cerc ori în capătul unui spline sau al unei curbe conice deschise. Alegeți curba, apoi punctul de tangență.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Șterge", "hole_presets.delete_prompt": "Ștergeți această presetare personală? Găurile existente rămân neschimbate.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Aceste piese sunt corpuri ale aceleiași componente: {bodies}. O îmbinare mișcă o componentă față de alta, deci fiecare piesă are nevoie de o componentă proprie.", "joint.rigid_group_one_component": "Aceste {count} corpuri se află toate într-o singură componentă (sau în proiectul însuși): folosiți mai întâi {command}.", "joint.rigid_group_select_components": "Selectați două sau mai multe componente.", + "joint_move.blocked_hint": "Verificați tipul îmbinării (de exemplu Rigidă), fixarea, limitele îmbinării și valorile conduse de parametri.", + "joint_move.blocked_title": "Această piesă nu poate fi mutată", + "joint_move.coarse": "Pas de rotire lângă axă (°)", + "joint_move.distance": "Distanță față de start", + "joint_move.done": "Gata", + "joint_move.fine": "Pas de rotire departe de axă (°)", + "joint_move.fixed": "Această piesă nu are nicio axă de îmbinare mobilă.", + "joint_move.help": "Mută — trageți o săgeată sau un inel. Esc anulează tragerea curentă.", + "joint_move.legacy_hint": "Dezactivați pentru a folosi panoul Mută anterior.", + "joint_move.precision": "Trageți în afara ghidului cu linie întreruptă pentru rotire fină.", + "joint_move.rotation": "Rotire față de start", + "joint_move.select": "Selectați o față sau o piesă din ansamblu, apoi alegeți Mută.", + "joint_move.settings": "Mutare în ansamblu", + "joint_move.step": "Pas", + "joint_move.use_handles": "Folosește mânerele de mișcare ale îmbinărilor pentru Mută în ansamblu", "keymap.command_id": "ID comandă", "keymap.empty": "Nicio comandă corespunzătoare", "keymap.use_anyway": "Folosește totuși {key} aici", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+tragere cu butonul stâng: orbitare, Alt+butonul din mijloc: panoramare, Alt+butonul drept: zoom; tragerea cu butonul din mijloc face de asemenea panoramare", "nav.openscad.help": "Tragerea cu stânga pe spațiu gol orbitează, Shift+tragere cu stânga selectează cu casetă, tragerea geometriei schiței o editează; tragerea cu dreapta sau cu mijlocul panoramează, Shift+tragere cu dreapta sau Shift+tragere cu mijlocul face zoom", "nav.perspective.sketch": "Editarea schiței folosește vizualizarea ortografică. Finalizați schița pentru a comuta la perspectivă.", + "nav.right_drag_orbits.help": "Dezactivat: tragerea cu dreapta nu mișcă camera, iar butonul drept doar deschide meniul contextual. Alegerea mapării de mouse SolidWorks dezactivează această opțiune.", + "nav.right_drag_orbits.label": "Tragerea cu dreapta orbitează", "nav.solidworks.help": "Tragerea cu mijlocul orbitează, Ctrl+mijloc panoramează, Shift+mijloc face zoom, tragerea cu dreapta orbitează", "nav.tinkercad.help": "Tragerea cu dreapta orbitează, tragerea cu mijlocul sau Shift+tragere cu dreapta panoramează", "nav.touchpad.help": "Țineți apăsat Shift și mișcați indicatorul pentru panoramare; țineți apăsat Alt/Option și mișcați-l pentru orbitare. Nu este nevoie de butonul mouse-ului. Derularea verticală mărește/micșorează.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Personalizează bara de navigare…", "navbar.custom.design": "Proiect", "navbar.custom.duplicate": "Acest element este deja pe bară.", + "navbar.custom.edge": "Margine", + "navbar.custom.edge_bottom": "Jos", + "navbar.custom.edge_top": "Sus", "navbar.custom.fade": "Estompare", "navbar.custom.full": "Eliminați un element pentru a adăuga altul.", "navbar.custom.hidden_design": "Ascuns în Proiect", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Grosime", "parts.dimension.width": "Lățime", "parts.family.bearing": "Rulment radial cu bile (gabarit)", + "parts.family.button_head_cap_screw": "Șurub cu cap bombat și locaș hexagonal", "parts.family.dowel_pin": "Știft cilindric (de centrare)", "parts.family.hex_bolt": "Șurub / bolț cu cap hexagonal", "parts.family.hex_nut": "Piuliță hexagonală", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Netezește muchiile zimțate.", "prefs.applies_on_restart": "Se aplică la repornire", "prefs.assistant": "Asistent", + "prefs.auto_operation": "Alege automat operația", + "prefs.auto_operation_note": "Ferestrele operațiilor aleg Unire, Corp nou sau Decupează în funcție de ce atinge materialul nou, până când alegeți una. Dezactivat: păstrează operația cu care se deschid.", "prefs.axes": "Axe", "prefs.axis": "Axă", "prefs.bodies_folders": "Foldere pentru corpuri", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Centru jos", "prefs.bottom_right": "Dreapta jos", "prefs.browser": "Navigator", + "prefs.browser_eye_left": "Ochiul lângă săgeata de restrângere", + "prefs.browser_eye_left_note": "Rândurile din navigator își afișează ochiul de afișare/ascundere lângă săgeata de restrângere, ca înainte, în loc de o coloană la capătul din dreapta al rândurilor.", + "prefs.browser_reveal_on_hover": "Afișează la survol", + "prefs.browser_show_note": "Afișat ține navigatorul pe ecran. Afișează la survol îl ține ascuns până când indicatorul ajunge pe locul lui sau pe fila subțire de la marginea lui stângă, iar navigatorul rămâne deschis cât timp indicatorul este în interiorul lui sau cât timp un meniu al lui este deschis.", + "prefs.browser_shown": "Afișat", "prefs.built_in": "Încorporat", "prefs.button_left": "Tragere cu butonul stâng", "prefs.button_middle": "Tragere cu butonul din mijloc", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Intră în vigoare după repornirea SolidCraft. WGPU_ADAPTER_NAME suprascrie această alegere.", "prefs.graphics_note": "Alegeți între viteză și netezime. Fiecare opțiune își arată efectul pe un eșantion.", "prefs.graphics_presets_note": "Presetările stabilesc împreună opțiunile de mai jos.", + "prefs.hide_constraint_glyphs_zoomed_out": "Ascunde simbolurile de constrângere la micșorare", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Simbolurile de constrângere dispar din vizualizare când schița este micșorată atât de mult încât ar aglomera-o. Dezactivat: rămân la o dimensiune fixă pe ecran la orice nivel de zoom.", "prefs.history": "Istoric", "prefs.history_origin_bodies": "Afișează originea și corpurile în partea de sus", "prefs.history_origin_bodies_note": "Istoricul din stânga sau de sub navigator începe cu dosarul Corpuri al componentei active și cu Originea, cu ochii și meniurile lor ca în navigator. Istoricul din partea de jos rămâne neschimbat.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Compact fixează panourile la marginile ferestrei. Transparența navigatorului este disponibilă în modul Plutitor. Schimbați partea Asistentului în pagina Asistent.", "prefs.left": "Stânga", "prefs.left_click": "Clic stânga", + "prefs.line_drag_draws_one": "Apăsarea și tragerea desenează o singură linie", + "prefs.line_drag_draws_one_note": "În instrumentul Linie, apăsarea, tragerea și eliberarea desenează o singură linie de la punctul apăsării la cel al eliberării, iar linia se termină acolo. Clicurile desenează în continuare un lanț. Dezactivat: o tragere plasează un punct acolo unde este eliberată, la fel ca un clic.", "prefs.linked_designs": "Proiecte legate", "prefs.live_input": "Intrare în timp real", "prefs.live_preview": "Previzualizare în timp real", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "Fișiere STEP și de import", "prefs.max_file_size_web": "Aplicația web deschide fișiere STEP și de import de până la 512 MB și proiecte de până la 256 MB: o filă de browser nu poate conține fișiere mai mari. Aplicația desktop permite creșterea acestor limite.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Pictograme mari", + "prefs.menu_list": "Listă", + "prefs.menus": "Meniuri", "prefs.minutes": "minute", "prefs.minutes_suffix": " min", "prefs.mouse": "Mouse și trackpad", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Plasare", "prefs.prefer_free_sketch_points": "Preferă punctele de schiță mobile când punctele se suprapun", "prefs.preview_choices": "Previzualizarea opțiunilor alese", + "prefs.print_face_highlight": "Evidențierea feței de imprimare", + "prefs.print_face_highlight_tip": "Cât de puternic este colorată și conturată fața de imprimare a unui corp în vizualizare. 0 % nu afișează evidențiere.", + "prefs.profile_active": "Profil activ", + "prefs.profile_applied": "Profil aplicat.", + "prefs.profile_changed": "Setările s-au modificat de la salvarea profilului. Salvează le păstrează în el.", + "prefs.profile_deleted": "Profil șters.", + "prefs.profile_export": "Exportă…", + "prefs.profile_exported": "Profil exportat.", + "prefs.profile_imported": "Profil importat. Alegeți-l la Profil activ pentru a trece la el.", + "prefs.profile_name_hint": "Numele profilului, de ex. Laptop", + "prefs.profile_rename": "Redenumește", + "prefs.profile_renamed": "Profil redenumit.", + "prefs.profile_save": "Salvează", + "prefs.profile_saved": "Setările curente au fost salvate în profil.", + "prefs.profile_skipped": "Profil aplicat; {n} setări și-au păstrat valoarea curentă (vedeți jurnalul).", + "prefs.profiles": "Profiluri de setări", + "prefs.profiles_note": "Comenzile rapide, bara de instrumente, navigarea, meniul radial și setările interfeței, păstrate împreună sub un nume și comutate dintr-odată.", + "prefs.project_options_dialog": "Dialog cu opțiuni de proiecție", + "prefs.project_options_dialog_note": "Activat: alegeți Geometrie sau Corpuri și Legătură de proiecție înainte de a proiecta. Dezactivat: se păstrează fluxul anterior, cu alegere directă.", "prefs.quality_default": "implicit", "prefs.quality_fastest": "cel mai rapid", "prefs.quality_slowest": "cel mai lent", "prefs.quality_smoother": "mai neted", + "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", + "prefs.ribbon_reveal": "Afișează rezultatele căutării în panglică", + "prefs.ribbon_reveal_note": "Trecerea cursorului peste un rezultat din Caută comenzi arată unde se află comanda în panglică: fila, butonul sau rândul ei din lista derulantă a unui panou. O comandă pornită din căutare clipește acolo, iar o comandă în curs pornită dintr-o listă derulantă îi marchează panoul.", "prefs.right_click": "Clic dreapta", "prefs.saving": "Salvare automată și recuperare", "prefs.scale_note": "Și Ctrl + / Ctrl − în fereastră. Butoanele au 28 px la 100%.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Nicio setare corespunzătoare", "prefs.select": "Selectează", "prefs.select_one_item": "Selectați un element", + "prefs.select_other_hidden": "Selectează geometria ascunsă cu Selectează altul", + "prefs.select_other_hidden_help": "Activat: include corpurile ascunse, schițele ascunse și muchiile acoperite din apropierea indicatorului. Dezactivat: folosește lista inițială de geometrie vizibilă de sub indicator.", "prefs.selecting": "Selectare", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift doar adaugă; Ctrl / Cmd comută", "prefs.shift_add_note": "Activat: Shift adaugă, Ctrl / Cmd comută. Dezactivat (implicit): ambele comută.", "prefs.shortcuts": "Comenzi rapide", + "prefs.single_surface_analysis": "Folosește o singură analiză de suprafață", + "prefs.single_surface_analysis_help": "Când este dezactivat, umbrirea suprafeței și Continuitatea pot rămâne active împreună. Activarea acestei opțiuni păstrează ultima analiză pornită sau modificată.", + "prefs.sketch_align_points": "Aliniază cu alte puncte de schiță în timpul desenării", + "prefs.sketch_align_points_note": "În timpul desenării, un punct se aliniază simultan pe orizontală cu un punct de schiță și pe verticală cu altul, cu un ghid punctat, iar clicul păstrează ambele alinieri. Dezactivat: doar cu punctul anterior, pe o singură axă odată.", "prefs.sketch_conflict_colour": "Geometrie în conflict, în culoarea de conflict", "prefs.sketch_conflict_colour_note": "Cât timp constrângerile unei schițe sunt în conflict, curbele și punctele pe care le leagă o constrângere eșuată sunt desenate și ele în culoarea de conflict, nu doar simbolul sau cota ei. Dezactivat: își păstrează culorile obișnuite.", + "prefs.sketch_construction_lines": "Scule extinse pentru dreptele de construcție", "prefs.sketch_exit_view": "Când se încheie o schiță", "prefs.sketch_exit_view.keep": "Păstrează vizualizarea curentă", "prefs.sketch_exit_view.previous": "Revino la vizualizarea anterioară", "prefs.sketch_exit_view.unless_rotated": "Revino, dacă nu a fost rotită", "prefs.sketch_exit_view_note": "Unde ajunge vizualizarea după Finalizează schița, Anulează sau un dublu Esc.", + "prefs.sketch_open_look_at": "Rotește vizualizarea spre schiță la deschiderea ei", + "prefs.sketch_open_look_at_note": "Dezactivat: crearea sau editarea unei schițe lasă vizualizarea neschimbată. Comanda Privește spre orientează vizualizarea spre schiță atunci când doriți.", + "prefs.sketch_recent_snap": "Agățare automată la centrele și punctele de mijloc recente", + "prefs.sketch_recent_snap_note": "În timpul desenării, cursorul se agață acolo unde se intersectează ghidurile ultimelor două centre de cerc sau puncte de mijloc ale liniilor drepte. Dezactivat: aceste ghiduri rămân doar vizuale, iar cursorul nu se mișcă.", "prefs.sketch_selection": "Schiță și selecție", "prefs.sketch_selection_heading": "Schiță și selecție", "prefs.sketch_selection_note": "Cum se agață schițele și cum selectează clicurile.", + "prefs.sketch_state_icons": "Pictogramele schițelor în culoarea stării lor", + "prefs.sketch_state_icons_note": "În Istoric și în navigator, pictograma unei schițe preia culoarea stării ei (constrângeri insuficiente, complet constrânsă sau în conflict), iar sugestia ei indică starea. Dezactivat: toate schițele au aceeași pictogramă.", "prefs.sketch_tab_always": "Păstrează fila Schiță afișată", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Sursă", "prefs.speed": "Viteză", "prefs.stand_up_note": "După ce un fișier STEP sau IGES se deschide cu Z în sus, oferă o rotire cu un sfert de tură în jurul lui X.", + "prefs.stand_up_unknown": "Pentru fișiere din programe necunoscute", + "prefs.stand_up_unknown.ask": "Întreabă într-un dialog", + "prefs.stand_up_unknown.note": "Afișează o notă", + "prefs.stand_up_unknown.off": "Nu oferi", + "prefs.stand_up_unknown_note": "Cum se face oferta atunci când antetul fișierului nu indică niciun program cunoscut că salvează cu Y în sus. O notă apare doar când fișierul pare să zacă pe spate; Întreabă într-un dialog întreabă pentru fiecare astfel de fișier, ca înainte.", "prefs.status_bar": "Bară de stare", "prefs.status_bar_note": "O bară subțire de-a lungul marginii de jos a ferestrei arată ce se editează, starea schiței deschise prin culoarea ei și unitățile proiectului; un clic pe unități le schimbă. Starea schiței nu mai apare atunci în colțul vizualizării.", "prefs.status_done": "Gata", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Notificări", "prefs.toggle_item": "Adaugă elementul sau scoate-l din nou", "prefs.toolbar": "Bară de instrumente", + "prefs.toolbar_tabs": "File ale barei de instrumente", "prefs.top": "Sus", + "prefs.trackpad_zoom_modifier": "Zoom cu derularea cu două degete ținând apăsat", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Țineți apăsată doar această tastă pentru a face zoom la indicator cu derularea verticală. Dezactivat păstrează comportamentul actual al derulării.", + "prefs.trackpad_zoom_modifier.off": "Dezactivat", "prefs.translucent": "Translucid", "prefs.under_browser": "Sub navigator", "prefs.under_cursor": "Sub cursor", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Fereastră de vizualizare", + "prefs.viewport_analysis_status": "Afișează starea analizelor deasupra ferestrei de vizualizare", + "prefs.viewport_analysis_status_help": "Afișează analizele active deasupra ferestrei de vizualizare, deschide legenda fiecăreia din elementul ei și folosește Esc pentru a dezactiva cea mai recentă analiză. Dezactivați pentru a reveni la legendele din colțuri și la comportamentul anterior al tastei Esc.", + "prefs.viewport_background": "Fundal", + "prefs.viewport_background_colour": "Culoare", + "prefs.viewport_background_theme": "Gradientul temei", "prefs.wheel": "Rotiță", "prefs.wheel_trackpad": "Rotiță și trackpad", "prefs.workspace": "Spațiu de lucru", + "prefs.zoom_stop_at_model": "Oprește zoomul la model", + "prefs.zoom_stop_at_model_tip": "Mouse-ul 3D și aplicația dispozitivului fac zoom spre suprafața din mijlocul vizualizării; zoomul prin tragere păstrează punctul unde a început tragerea. Dezactivat: zoomul poate trece prin model. Rotița și ciupirea folosesc separat Zoom spre cursor", "prefs.zoom_toward_cursor": "Zoom spre cursor", "properties.title": "Proprietăți: {name}", "recovery.days_ago": "acum {count} zile", "recovery.detail": "{file} · {features} operații · salvat automat {when}", + "recovery.discard_all": "Renunță la toate", + "recovery.discard_all_detail": "Această acțiune nu poate fi anulată.", + "recovery.discard_all_title": "Renunțați la salvările automate ({count})?", + "recovery.discard_all_title_one": "Renunțați la 1 salvare automată?", "recovery.hours_ago": "acum {count} h", "recovery.just_now": "chiar acum", "recovery.min_ago": "acum {count} min", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Pondere", "render.appearance_physics_separate": "Aspectul vizual nu atribuie densitate, date termice sau alte proprietăți tehnice.", "render.appearances": "Aspecte", + "render.assign_help": "Faceți clic pentru a aplica la selecție; trageți pentru a previzualiza și eliberați pentru a plasa sau faceți clic fără nimic selectat pentru a picta.", "render.availability_native": "Compoziție nativă; verificați înainte de aplicare", "render.availability_projection": "Proiecție acceptată; vedeți domeniul și omisiunile", "render.availability_reference": "Date de referință; nu sunt o atribuire automată", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Prag liniar al zonelor luminoase", "render.browse_capabilities": "Inspectează catalogul complet de capabilități", "render.browse_environments": "Răsfoiește mediile HDR originale", + "render.browse_looks": "Răsfoiește…", "render.browse_native_scenes": "Răsfoiește studiourile native", "render.browse_physical": "Inspectează referințele fizice", "render.browse_quality": "Inspectează specificațiile de calitate", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Transparență: doar suprafețe opace; canalul alfa al fundalului este acceptat.", "render.check_sorted_alpha": "Transparență: aproximare alfa sortată.", "render.check_transmission": "Transparență: transmisie fizică.", + "render.children.absolute_floor": "Prag absolut", + "render.children.adaptive": "Adaptiv", + "render.children.add_look_reason": "Adăugați un stil pentru a-i edita starea de activare și intensitatea.", + "render.children.all_shots": "Toate cadrele", + "render.children.bloom": "Bloom", + "render.children.check": "Verificare", + "render.children.check_now": "Verifică acum", + "render.children.chromatic": "Decalaj cromatic", + "render.children.compact": "Compact", + "render.children.contrast": "Contrast", + "render.children.copy_image": "Copiază", + "render.children.copy_job": "Copiază JSON-ul sarcinii", + "render.children.copy_request": "Copiază JSON-ul cererii", + "render.children.coverage_error": "Eroare de acoperire", + "render.children.cpu": "Trasare de raze pe CPU", + "render.children.denoise": "Reduce zgomotul în imaginea finală", + "render.children.depth": "Adâncimea căilor", + "render.children.distortion": "Distorsiune", + "render.children.edge_samples": "Eșantioane de margine", + "render.children.effects_banner": "Aceste efecte necesită path tracing pe GPU. Deschiderea acestei secțiuni nu schimbă niciodată metoda.", + "render.children.enable_adaptive": "Activați piloții independenți pentru a edita limitele adaptive.", + "render.children.enable_bloom": "Activați bloom pentru a-i edita intensitatea, pragul și raza.", + "render.children.finishing": "Finisare", + "render.children.flare": "Reflexie", + "render.children.fresnel": "Straturi Fresnel", + "render.children.ghosts": "Inele fantomă", + "render.children.glass": "Transport prin sticlă", + "render.children.go_to": "Mergi la {editor}", + "render.children.grain": "Granulație", + "render.children.halo": "Halou", + "render.children.hdr": "HDR", + "render.children.keep_master": "Păstrează masterul liniar brut", + "render.children.last_result": "Ultima: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Efecte de obiectiv", + "render.children.look_summary": "{tone} · {count} stiluri", + "render.children.looks": "Stiluri creative", + "render.children.memory": "Memorie", + "render.children.method": "Metodă", + "render.children.minimum": "Minim", + "render.children.needs_cpu": "Necesită ray tracing pe CPU. Deschiderea acestei secțiuni nu schimbă niciodată metoda.", + "render.children.nested": "Dielectrice imbricate certificate", + "render.children.not_checked": "Neverificat", + "render.children.path": "Trasare de căi", + "render.children.pilots": "Eșantioane pilot", + "render.children.preview": "Date de previzualizare", + "render.children.progressive_reason": "Alegeți Progresiv pentru a edita numărul de eșantioane și sămânța.", + "render.children.radius": "Rază", + "render.children.reflect_depth": "Adâncimea reflexiei", + "render.children.reflect_samples": "Eșantioane de reflexie", + "render.children.regrade": "Recorectare de culoare", + "render.children.relative_error": "Eroare relativă", + "render.children.render": "Randare", + "render.children.render_shot": "Randează cadrul", + "render.children.renderer": "Motor de randare", + "render.children.renderers": "Motoare de randare", + "render.children.request": "Această cerere", + "render.children.reset_finishing": "Resetează finisarea", + "render.children.review_quality": "Examinează o specificație de calitate…", + "render.children.rgb_gain": "Amplificare RGB", + "render.children.samples": "Eșantioane", + "render.children.sampling": "Eșantionare", + "render.children.saturation": "Saturație", + "render.children.scene_summary": "{bodies} corpuri · {triangles} triunghiuri", + "render.children.seed": "Sămânță", + "render.children.sharpening": "Accentuare", + "render.children.single_image": "Imagine unică", + "render.children.soft_focus": "Focalizare moale", + "render.children.streak": "Dungă", + "render.children.strength": "Intensitate", + "render.children.studio": "Studio", + "render.children.threshold": "Prag", + "render.children.tone": "Ton", + "render.children.under_hood": "Detalii tehnice", + "render.children.vignette": "Vinietă", + "render.children.workstation": "Stație de lucru", + "render.choose_look": "Alegeți mai întâi un stil în Aspecte", "render.chromatic_offset": "Decalaj cromatic al marginilor", "render.clear_stage": "Elimină decorul salvat", "render.copy_request": "Copiază JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Intensitatea reflexiei zonelor luminoase", "render.flare_threshold": "Prag de reflexie liniar în scenă", "render.fold_browser": "Restrânge navigatorul", + "render.frame_aspect": "Raport de aspect", + "render.frame_centre_mark": "Marcaj central", + "render.frame_copy_limit": "Niciun nume disponibil pentru cadrul duplicat", + "render.frame_copy_name": "{name} copie {number}", + "render.frame_custom": "Personalizat", + "render.frame_distance_unit": " × dimensiune", + "render.frame_duplicate": "Duplică", + "render.frame_exposure": "Expunere", + "render.frame_fov": "Câmp vizual", + "render.frame_guides": "Ghiduri", + "render.frame_invalid_aspect": "Presetare de raport de aspect nevalidă", + "render.frame_lens": "Câmp vizual {fov}° · ≈ {mm} mm", + "render.frame_name": "Nume", + "render.frame_output_size": "Dimensiunea de ieșire", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Portret", "render.frame_presets": "Presetări de cadru", "render.frame_presets_pending": "Va fi disponibil odată cu editorul de cadre", + "render.frame_projection": "Proiecție", + "render.frame_render_shot": "Randează acest cadru", + "render.frame_rule_thirds": "Regula treimilor", + "render.frame_safe_area": "Zonă sigură", + "render.frame_shot_title": "Cadru · {name}", + "render.frame_square": "1:1 · Pătrat", + "render.frame_tall": "9:16 · Vertical", + "render.frame_thirds": "Treimi", + "render.frame_wide": "16:9 · Panoramic", "render.gpu_depth": "Adâncime maximă a traseului", "render.gpu_options_help": "Eșantioanele, adâncimea, valoarea inițială și expunerea se salvează cu această configurație. Se salvează doar metoda de imagine activă.", "render.gpu_path_failed": "Path tracerul GPU nu este disponibil", @@ -2360,6 +2627,7 @@ "render.heading_results": "REZULTATE", "render.heading_shots": "CADRE", "render.hide_shots_label": "Ascunde banda de cadre", + "render.hover_look": "{object} · {look} · clic pentru a selecta", "render.image_advanced": "Comenzi GPU avansate", "render.image_cancelled": "Randare anulată", "render.image_cancelling": "Se anulează", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Folosește aproximarea uniformă, fără efecte spațiale sau avansate", "render.library_usable": "Proiecții executabile", "render.library_users": "Folosit de", + "render.light_aim": "Îndreptată spre", "render.light_anchor_pending": "Schimbarea sistemului de coordonate al luminii nu este încă acceptată.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Alegeți ray tracing pe CPU sau un studio GPU explicit pentru a edita luminile. Luminile vechi ale studioului GPU rămân doar pentru citire.", + "render.light_behaviour": "COMPORTAMENT", + "render.light_default_fill": "Umplere", + "render.light_default_key": "Principală", + "render.light_default_number": "Lumina {number}", + "render.light_default_rim": "Contur", + "render.light_delete": "Șterge", + "render.light_distance": "Distanță", + "render.light_duplicate": "Duplică", + "render.light_elevation": "Elevație", + "render.light_gpu_punctual": "Luminile punctiforme și spot sunt disponibile cu ray tracing pe CPU.", + "render.light_hard_shadow": "Această lumină are umbre dure; moliciunea sursei nu este disponibilă.", + "render.light_intensity": "Intensitate", + "render.light_missing": "Această lumină nu mai există. Selectați o lumină în Navigator.", + "render.light_place_highlight": "Plasează după reflexie", + "render.light_place_pending": "Plasarea după reflexie necesită alegerea luminii în fereastra de vizualizare și nu este încă acceptată.", + "render.light_position": "POZIȚIE", + "render.light_preview_help": "Vizualizarea live este fără umbre; luminile de suprafață folosesc o aproximare cu un eșantion central. Randarea folosește modelul complet de lumină.", + "render.light_product_centre": "Centrul produsului", + "render.light_size": "Dimensiune", + "render.light_softness": "Moliciune", + "render.light_temperature": "Temperatura culorii", + "render.light_temperature_help": "Nuanță aproximativă de corp negru; salvată ca RGB liniar.", + "render.light_type": "Tip", + "render.light_type_area": "Suprafață", + "render.light_type_point": "Punctiformă", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Soare", + "render.light_view_only": "Solo și dezactivarea afectează această vizualizare și randările ei; nu sunt salvate niciodată în proiect.", + "render.light_world_origin": "Originea lumii", + "render.lighting_aim_point": "Punct de țintire", + "render.lighting_apply": "Aplică presetarea", + "render.lighting_area_samples": "Eșantioane de suprafață", + "render.lighting_budget": "Buget", + "render.lighting_budget_help": "Numerele sunt doar pentru citire. Adăugați, duplicați sau ștergeți lumini pentru a le modifica; metoda stabilește limitele.", + "render.lighting_colour": "Culoare", + "render.lighting_consent": "Înlocuiește luminile direcționale cu această presetare", + "render.lighting_custom": "Lumini personalizate", + "render.lighting_custom_help": "Alegerea opțiunii Personalizat transformă presetarea în lumini editabile, fiecare cu propriul rând în arbore.", + "render.lighting_direction": "Direcție X Y Z", + "render.lighting_direction_help": "Sau introduceți o direcție spre lumină (X Y Z).", + "render.lighting_distance_unit": " × dimensiune", + "render.lighting_edge_u": "Vectorul laturii U", + "render.lighting_edge_v": "Vectorul laturii V", + "render.lighting_falloff": "Atenuare", + "render.lighting_falloff_help": "Intensitatea este indicată la distanța de referință.", + "render.lighting_height": "Înălțime V", + "render.lighting_minimum": "Minim", + "render.lighting_mute": "Dezactivează această lumină", + "render.lighting_point": "Punct", + "render.lighting_preset": "Presetare de studio", + "render.lighting_preset_cpu": "Presetările de studio direcționale necesită ray tracing pe CPU. Iluminarea GPU folosește lumini explicite de tip Soare și Suprafață.", + "render.lighting_preset_row": "Presetare", + "render.lighting_radiance": "Radianță", + "render.lighting_reference": "Referință", + "render.lighting_review_help": "Înlocuiește lista de lumini direcționale. Păstrează luminile punctiforme și de suprafață, mediul, umbrele, camera și toate celelalte setări ale cadrului.", + "render.lighting_review_title": "Examinează presetarea de iluminare", + "render.lighting_samples_cpu": "Eșantioanele pentru luminile de suprafață se aplică la ray tracing pe CPU.", + "render.lighting_samples_need_area": "Adăugați o lumină de suprafață CPU pentru a seta eșantioanele de suprafață.", + "render.lighting_samples_reason": "Eșantioanele de suprafață trebuie să fie 1, 4, 16 sau 64.", + "render.lighting_shadow_cpu": "Comutatorul pentru umbre necesită ray tracing pe CPU. Path tracingul pe GPU aruncă întotdeauna umbre.", + "render.lighting_shadows": "Aruncă umbre", + "render.lighting_shadows_section": "Umbre și eșantionare", + "render.lighting_shape": "Formă", + "render.lighting_solo": "Solo pentru această lumină", + "render.lighting_source": "Sursă", + "render.lighting_source_section": "Sursă de lumină", + "render.lighting_spot_preserved": "Această lumină punctiformă își păstrează conul de spot salvat până la schimbarea tipului.", + "render.lighting_sun_distance": "Direcția soarelui nu depinde de distanță și de punctul de țintire.", + "render.lighting_unsupported": "neacceptat", + "render.lighting_used": "{used} din {limit}", + "render.lighting_width": "Lățime U", + "render.lighting_world_position": "Poziție în lume X Y Z", "render.lights_fixed_to": "Luminile fixate de", "render.linear_help": "Păstrează un master RGBA în virgulă mobilă pe 32 de biți înainte de eliminarea zgomotului, expunere, maparea tonurilor, filtre și fundal. sRGB liniar/D65; premultiplicat cu acoperirea. Memoria suplimentară este acceptată explicit. Fără compresie sau treceri suplimentare.", "render.linear_retain": "Păstrează EXR liniar brut", "render.linear_unavailable": "Cadrul finalizat selectat nu are un EXR păstrat. Activați păstrarea înainte de randare; un PNG mai vechi nu poate recupera radianța brută.", "render.look_add": "Adaugă stil de culoare", + "render.look_applied": "S-a aplicat {name}", "render.look_bleach": "Inspirat de bleach bypass", "render.look_cool": "Rece", "render.look_cyanotype": "Inspirat de cianotipie", "render.look_faded": "Imprimare decolorată", + "render.look_material": "Material", "render.look_mono": "Monocrom", + "render.look_pasted": "Stil lipit", "render.look_sepia": "Sepia", "render.look_strength": "Intensitatea stilului", "render.look_teal_orange": "Turcoaz / portocaliu", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Avansat", "render.output_backend_ready": "Gata", "render.output_check": "Verifică", + "render.output_check_failed": "Verificare · eroare", "render.output_check_help": "Rulați Verifică pentru a examina această cerere fără a porni o randare.", "render.output_check_summary": "Verificare · Note: {count}", "render.output_copy": "Copiază", + "render.output_copy_report": "Copiază raportul", "render.output_destination": "Destinație", "render.output_destination_help": "Alegeți fișierul de destinație la exportul imaginii finalizate.", "render.output_dimensions": "Dimensiune", @@ -2604,6 +2952,7 @@ "render.output_fit": "Potrivire", "render.output_format": "Format", "render.output_format_status": "PNG este imaginea de afișare finalizată, exact așa cum este. EXR opțional pe GPU păstrează radianța brută FLOAT32 liniară la nivel de scenă, înainte de filtrele de afișare. Exportul nu înlocuiește niciodată memoria cache a previzualizării sau o altă sarcină.", + "render.output_grading": "Corectare de culoare", "render.output_image": "Imagine", "render.output_largest": "max. {width} × {height}", "render.output_lock_ratio": "Blochează raportul de aspect", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "se așteaptă contractul de calitate", "render.output_ratio_refused": "Dimensiunea legată depășește limita de dimensiune a acestei metode. Dimensiunea anterioară este păstrată; deblocați raportul sau alegeți o valoare mai mică.", "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_ok": "Dimensiune OK · {width} × {height} px", "render.output_swap_orientation": "Interschimbă lățimea și înălțimea", "render.output_tools_inspector": "Editați dimensiunea și formatul în inspectorul Ieșire.", @@ -2629,6 +2980,7 @@ "render.page_output": "Ieșire", "render.page_scene": "Scenă", "render.paint_label": "Vopsește", + "render.painting_with": "Se pictează cu {name}", "render.passive_stage": "Podea și decor pasiv", "render.passive_stage_help": "Panourile pasive finite aparțin doar imaginilor randate. Ele nu adaugă corpuri CAD sau masă. Plasarea este fixă după aplicare; o plasare care intersectează limitele produsului este refuzată. CPU nu oferă iluminare indirectă difuză.", "render.perspective": "Cameră în perspectivă", @@ -2650,6 +3002,7 @@ "render.pick_help": "Alegeți Corp, Față sau Componentă. Faceți clic într-un spațiu gol pentru a goli selecția; Shift sau Ctrl adaugă sau comută. Evidențierile apar doar în vizualizarea live a produsului.", "render.pick_label": "Alege", "render.pick_up_label": "Preia", + "render.pick_up_prompt": "Faceți clic pe un obiect pentru a-i prelua stilul", "render.pick_waiting": "Alegerea produsului nu este încă acceptată.", "render.post_contrast": "Contrast", "render.post_gain_b": "Amplificare albastru", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Distorsiune radială", "render.radial_fit": "Încadrează produsul", "render.radial_save": "Salvează cadrul", + "render.recent_looks": "Stiluri recente", "render.regrade_action": "Recorectează culorile master-ului brut (fără eliminarea zgomotului)", "render.regrade_help": "Creează un PNG separat din master-ul brut finalizat și selectat, folosind aceste comenzi de prezentare. Nu trasează raze, nu modifică dimensiunea, camera sau sursele de lumină capturate și nu suprascrie EXR-ul. Eliminarea zgomotului nu este reaplicată: master-urile brute nu rețin ghidurile acesteia. Derivatul rămâne exportabil prin aceleași comenzi ale sarcinii.", "render.regrade_label": "Recorectare de culoare", "render.reload_settings": "Reîncarcă setările salvate", + "render.remove_override": "Elimină suprascrierea", + "render.replace_all": "Înlocuiește tot", "render.replace_ground": "Confirmă înlocuirea decorului existent", "render.replace_label": "Înlocuiește", "render.replace_saved_surface": "Înlocuiește o asociere salvată existentă pentru această țintă", + "render.replace_with": "Înlocuiește cu", "render.restore_camera": "Restabilește camera salvată", "render.result_actual": "100%", "render.result_actual_help": "Un pixel de imagine pentru fiecare pixel de afișare.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Elimină asocierea salvată", "render.saved_surfaces": "Asocieri de suprafață salvate", "render.saved_surfaces_help": "Hărțile integrate fixate urmăresc instanța selectată, în milimetri locali. O țintă înlocuită sau lipsă este o eroare, niciodată o reatribuire automată.", + "render.scene_all_shots": "Toate cadrele ({count})", "render.scene_apply": "Aplică studioul revizuit", + "render.scene_batch_reason": "Toate cadrele necesită coada de randare a cadrelor salvate, care nu este încă disponibilă.", + "render.scene_check_now": "Verifică acum", + "render.scene_check_passed": "Verificare reușită · {notes} note", + "render.scene_check_stale": "Verificare depășită · verificați din nou", + "render.scene_check_unchecked": "Neverificat încă · 0 note", "render.scene_consent": "Confirmă înlocuirea iluminării, a mediului, a expunerii și a decorului", + "render.scene_light_summary": "{environment} · {lights} lumini", + "render.scene_look_summary": "{looks} stiluri · {overrides} suprascrieri", + "render.scene_open_check": "Deschide Verificare", "render.scene_preserved_fields": "Camera, eșantionarea, dimensiunile, asocierile optice, geometria CAD și materialele de inginerie sunt păstrate.", "render.scene_preserves_design": "Compune iluminarea și un decor pasiv pentru subiectul vizibil. Geometria, materialele de inginerie și camera rămân separate.", + "render.scene_ready": "Gata de randare", + "render.scene_reminder": "Scena păstrează proiectul. Geometria, proprietățile tehnice și CAD-ul nu sunt modificate niciodată din Randare.", + "render.scene_reminders": "Mementouri", + "render.scene_renderer_ready": "{renderer} pregătit", "render.scene_replaces": "Înlocuiește", "render.scene_review": "Rezolvă și revizuiește", "render.scene_review_help": "Revizuiți compoziția nativă completă înainte de aplicare. Specificațiile avansate SCENE neasociate nu sunt substituite.", "render.scene_scope_details": "Domeniul compoziției", + "render.scene_stages": "Etape", "render.search_appearances": "Caută aspecte ({count})", "render.setup_legacy_environment": "Această configurație mai veche folosește rotația sau intensitatea mediului în format vechi. Valorile sunt păstrate; convertiți-le explicit înainte de a salva o lucrare completă de imagine.", "render.setup_name": "Numele configurării", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "CAMERĂ RANDARE", "render.shell_tab_lighting": "ILUMINARE", "render.shell_tab_scene": "SCENĂ RANDARE", + "render.shot_aperture": "Diafragmă", + "render.shot_distance": "Distanță", + "render.shot_dof": "Adâncime de câmp", "render.shot_duplicate": "Duplică cadrul salvat", + "render.shot_edit_output": "Editează în Ieșire", + "render.shot_exposure_reason": "Expunerea necesită ray tracing pe CPU sau path tracing pe GPU.", + "render.shot_exposure_section": "Expunere", + "render.shot_focus_distance": "Distanța de focalizare", + "render.shot_fov_reason": "Câmpul vizual necesită proiecție în perspectivă.", + "render.shot_frame_subject": "Încadrează subiectul", + "render.shot_invalid_camera": "Valorile camerei trebuie să fie finite și în intervalul permis.", + "render.shot_lens": "Obiectiv", + "render.shot_lens_enable_reason": "Activați adâncimea de câmp pentru a edita diafragma și distanța de focalizare.", + "render.shot_lens_gpu_reason": "Adâncimea de câmp necesită path tracing pe GPU.", + "render.shot_lens_perspective_reason": "Adâncimea de câmp necesită proiecție în perspectivă.", + "render.shot_look_selection": "Privește spre selecție", "render.shot_manager": "Cadre salvate", "render.shot_new_name": "Numele noului cadru", "render.shot_remove": "Elimină cadrul salvat", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Eliminați cadrul salvat?", "render.shot_rename": "Redenumește cadrul salvat", "render.shot_saved_only": "Salvați mai întâi ciorna curentă. Duplicarea și redenumirea păstrează exact opțiunile salvate și referințele la resurse.", + "render.shot_selection_reason": "Selectați un corp sau o față spre care să țintiți.", + "render.shot_size": "Dimensiune", + "render.shot_target": "Țintă", "render.shots_camera_changed": "Camera s-a schimbat de când a fost salvat acest cadru", "render.shots_name_help": "Introduceți un nume unic pentru cadrul curent.", "render.shots_no_result": "Nicio randare încă", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "încă neacceptat: selectarea produsului și preluarea aspectului", "render.wait_replace": "încă neacceptat: înlocuirea tuturor utilizărilor unui aspect", "render.wait_solo": "încă neacceptat: vizibilitatea luminilor și comenzile solo", + "render.wear_applies_to": "Se aplică la", + "render.wear_layer_count": "{count} straturi", + "render.wear_layer_one": "{count} strat", + "render.wear_layers": "Straturi", + "render.wear_no_layers": "Niciun strat", + "render.wear_selected_layer": "Stratul selectat", + "render.wear_title": "Uzură", "sample.bracket.about": "Flanșă de bază cu flanșe pe margini și o pliere de margine, cu regulă de 2 mm", "sample.bracket.name": "Suport din tablă", "sample.enclosure.about": "Cutie scobită cu buză, bosaje pentru șuruburi, un reazem și o prindere cu clichet", @@ -3030,6 +3426,7 @@ "select an axis": "selectați o axă", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "selectați mai întâi o axă, o muchie dreaptă sau circulară ori o față de cilindru, de con sau de tor", "select bodies first": "selectați mai întâi corpurile", + "select components first": "selectați mai întâi componentele", "select corner points, or the two lines of one corner": "selectați punctele colțurilor sau cele două linii ale unui colț", "select one corner point, or the two lines of one corner": "selectați un punct de colț sau cele două linii ale unui colț", "select sheet edges first": "selectați mai întâi muchiile tablei", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Faceți dublu clic pe o componentă pentru a o activa sau pentru a edita pe loc una legată", "settings.frame_rate_details_note": "Adaugă un grafic cu timpii ultimelor cadre, cu triunghiurile desenate deasupra, o histogramă a timpilor de cadru și ce a desenat GPU-ul. Faceți clic pe rata de cadre din vizualizare pentru a le afișa sau a le ascunde.", "settings.frame_rate_note": "Afișează cadrele pe secundă în colțul din dreapta jos al vizualizării.", + "settings.insert_lists_open_designs": "Inserează proiect listează mai întâi proiectele deschise în alte file", "settings.measure_axis_distances": "Afișează distanțele X/Y/Z", "settings.measure_axis_distances_help": "Afișează cotele pe axele globale alături de distanța totală cât timp Măsurare este deschis.", "settings.msaa_applies_on_restart": "Anti-aliasing se aplică la următoarea pornire a SolidCraft", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Defixează", "sketch is missing": "schița lipsește", "sketch.degrees_of_freedom": "{count} grade de libertate", + "sketch.dimension_added_driven": "Schița determină deja acea cotă, așa că a fost adăugată ca o cotă condusă (de referință)", + "sketch.dimension_applied": "Cotă aplicată", + "sketch.dimension_applied_defined": "Cotă aplicată; parametrul `{name}` a fost definit", + "sketch.dimension_applied_renamed": "Cotă aplicată; parametrul a fost numit `{name}`", "sketch.dof_left": "{count} grade de libertate rămase", "sketch.dof_left_one": "{count} grad de libertate rămas", "sketch.draw_curves": "Desenează ca {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Folosește fișierul de font {path}", "solid": "solid", "status.editing": "Se editează {name}", + "status.navigation": "Mapare mouse: {mapping}", + "status.navigation_tip": "Cum mișcă mouse-ul vizualizarea. Faceți clic pentru a alege altă mapare", "status.under_constrained": "Constrângeri insuficiente ({count} grade de libertate)", "status.under_constrained_one": "Constrângeri insuficiente ({count} grad de libertate)", "status.units_tip": "Unitățile proiectului. Faceți clic pentru a le schimba", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Păstrează operațiile și dosarele imbricate în istoric", "timeline.delete_folders_with_features_tip": "Șterge dosarele cu toate operațiile din ele și dosarele lor imbricate", "timeline.delete_many": "Șterge operațiile selectate ({count})", + "timeline.edited_rolled_back": "{name} s-a modificat, dar se află după marcajul de istoric, deci nu este calculat: mutați marcajul după el pentru a vedea rezultatul", "timeline.feature_error": "Eroare: {message}", "timeline.feature_hint": "Dublu clic pentru a edita · trageți pentru a reordona · clic dreapta pentru mai multe", + "timeline.feature_rolled_back": "Derulat înapoi: după marcajul de istoric, deci necalculat", "timeline.feature_warning": "Avertisment: {message}", "timeline.group": "Dosar nou de operații", "timeline.move_many": "Mută elementele cronologiei", "timeline.move_to_group": "Mută operațiile în dosar", "timeline.rename_group": "Redenumește dosarul de operații", "timeline.reorder_many": "Reordonează operațiile", + "timeline.suppress_features": "Suprimă operațiile", "timeline.suppress_folders_tip": "Suprimă sau anulează suprimarea tuturor operațiilor din dosare și din dosarele lor imbricate", "timeline.ungroup": "Elimină dosarele de operații", + "timeline.unsuppress_features": "Anulează suprimarea operațiilor", "to the face": "până la față", "to the next face": "până la fața următoare", "toolbar.customize_note": "Fila {tab}. Bifați o comandă pentru a-i da un buton în bara de instrumente. Puteți și să trageți un rând din lista derulantă a unui panou pe bara de instrumente, să trageți un buton în afara barei pentru a-l elimina sau să folosiți pionezul de pe un rând din lista derulantă.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Calibrarea mouse-ului 3D corectează axele inversate sau schimbate între ele și îi reglează viteza și senzația.", "ui.3d_mouse_offer_never": "Nu mai afișa", "ui.3d_mouse_offer_swap": "Treci la Hidraw (driver direct)", + "ui.3d_mouse_orbit": "Orbitare", + "ui.3d_mouse_orbit_classic": "Clasic", + "ui.3d_mouse_orbit_tip": "Pe model: mouse-ul 3D rotește vizualizarea în jurul părții modelului aflate în mijlocul vizualizării când începe rotirea, marcată de o bilă discretă. Clasic: în jurul centrului vizualizării", + "ui.3d_mouse_orbit_traced": "Pe model", "ui.3d_mouse_reading": "Valoare citită", "ui.3d_mouse_reset_axes": "Resetează atribuirea axelor", "ui.3d_mouse_reset_view": "Resetează vizualizarea", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Răsuciți capacul în sens invers acelor de ceasornic, țineți-l, apoi eliberați-l.", "ui.3d_mouse_switch_cap": "Calibrează ca dispozitiv cu revenire la centru", "ui.3d_mouse_switch_trackball": "Calibrează ca trackball", + "ui.3d_mouse_use_direct": "Folosește driverul direct", + "ui.3d_mouse_use_direct_tip": "Citește mouse-ul 3D direct (hidraw) în loc de prin spacenavd.", "ui.3d_mouse_view_axis": "Axa vizualizării", "ui.3d_mouse_view_off": "Dezactivat", "ui.3d_mouse_view_off_tip": "Profilul driverului mouse-ului 3D dezactivează această axă, deci mișcarea ei nu mișcă vizualizarea. Alegerea aici a mișcării altei axe le interschimbă pe cele două, iar acea mișcare nu mai mișcă vizualizarea.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Anulează", "ui.canvases": "Pânze", "ui.center": "Centru", + "ui.center_arc_follows_cursor": "Arcul după centru urmează direcția cursorului", + "ui.center_arc_follows_cursor_tip": "Un arc după centru merge în sens orar sau antiorar, după cum cursorul se rotește în jurul centrului său. Dezactivat: întotdeauna în sens antiorar de la început.", "ui.centerline": "Linie de axă", "ui.chain": "Lanț", "ui.chamfer": "Teșire", @@ -3570,6 +3986,7 @@ "ui.circle": "Cerc", "ui.circular_pattern": "Matrice circulară", "ui.clear": "Șterge", + "ui.clear_print_face": "Șterge fața de imprimare", "ui.clear_recent": "Golește recentele", "ui.clear_the_selection": "Șterge selecția", "ui.click_selects_whole_bodies": "Clicul selectează corpuri întregi", @@ -3604,6 +4021,15 @@ "ui.constant": "Constant", "ui.constraints_conflict": "Conflict de constrângeri", "ui.construction": "Construcție", + "ui.construction.angle_hint": "Dreaptă de construcție: clic pe un punct, apoi tastați un unghi sau alegeți o direcție", + "ui.construction.bisector_hint": "Bisectoare: alegeți două linii drepte care se intersectează, apoi soluția dorită", + "ui.construction.common_hint": "Tangentă comună: alegeți două cercuri/arce, apoi soluția dorită", + "ui.construction.contact_hint": "Tangentă: alegeți un cerc/arc sau un spline/o curbă conică deschisă, apoi punctul de tangență (curbe de formă liberă: capetele)", + "ui.construction.external_hint": "Tangentă: alegeți un cerc sau un arc, un punct exterior, apoi soluția dorită", + "ui.construction.free_hint": "Dreaptă de construcție: clic pe două puncte; clic dreapta pentru a relua", + "ui.construction.point_hint": "Dreaptă de construcție: clic pe punctul prin care trece", + "ui.construction.reference_hint": "Dreaptă de construcție: alegeți o linie dreaptă, apoi un punct; clic dreapta pentru a schimba referința", + "ui.construction.ribbon_label": "Linii aux.", "ui.context_menu": "Meniu contextual", "ui.continuity_g0": "Muchie G0", "ui.continuity_g1": "Tangent G1", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Scară finală", "ui.entities": "Entități", "ui.equal": "Egal", + "ui.equation_end": "Sfârșit", + "ui.equation_explicit": "Explicită", + "ui.equation_form": "Formă", + "ui.equation_note": "Variabila este un număr simplu: sin(t) o primește în radiani. Un rezultat fără unitate este în unitatea proiectului.", + "ui.equation_parametric": "Parametrică", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Rază echivalentă", "ui.expand": "Extinde", "ui.exportDxf": "Exportă DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "PROIECT", "ui.heading_draft": "ÎNCLINARE", "ui.heading_edit": "EDITARE", + "ui.heading_equation_curve": "CURBĂ DIN ECUAȚIE", "ui.heading_extrude": "EXTRUDARE", "ui.heading_fillet": "RACORDARE", "ui.heading_finish_editing": "FINALIZARE EDITARE", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Importă…", "ui.infinite": "Infinit", "ui.insert": "Inserează", + "ui.insert_design.browse": "Răsfoiește…", + "ui.insert_design.open_designs": "Proiecte deschise", + "ui.insert_design.saved_file": "{name} are modificări nesalvate, așa că a fost inserat fișierul său salvat. Salvați-l, apoi folosiți Actualizează legăturile pentru a aduce modificările.", + "ui.insert_design.unsaved": "Modificări nesalvate", "ui.inspect": "Inspectează", "ui.intersect": "Intersecție", "ui.isolate": "Izolează", @@ -3994,12 +4433,29 @@ "ui.polygon": "Poligon", "ui.preferences": "Preferințe", "ui.press_a_key": "apăsați o tastă…", + "ui.print_face_cleared": "{body}: fața de imprimare a fost ștearsă", + "ui.print_face_input": "Față de imprimare", + "ui.print_face_one_body": "O față de imprimare aparține unui singur corp: trimiteți acel corp singur pentru a o schimba", + "ui.print_face_pick": "Selectați o față plană a unui corp solid pentru a o face fața de imprimare", + "ui.print_face_set": "{body}: fața de imprimare a fost setată; Trimite la slicer așază corpul pe ea", "ui.profile": "Profil", "ui.profile_plane": "Planul profilului", "ui.profiles": "Profiluri", "ui.project": "Proiectează", "ui.project_face_edges_sketch": "Proiectează muchiile feței în schițe noi", "ui.properties": "Proprietăți", + "ui.properties.copy_matrix": "Copiază matricea", + "ui.properties.first_moments": "Momente de ordinul întâi", + "ui.properties.inertia": "Moment de inerție", + "ui.properties.inertia_note": "Față de centrul de masă, în axele lumii (g·{unit}²)", + "ui.properties.inertia_unavailable": "Indisponibil: plasa unui corp nu este închisă.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Se măsoară…", + "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", "ui.pull_direction": "Direcția de extragere", "ui.quality": "Calitate", "ui.quantity": "Cantitate", @@ -4045,6 +4501,7 @@ "ui.revolve": "Revoluție", "ui.ribbon_exit": "Ieșire", "ui.ribbon_labels": "Afișează text sub pictogramele panglicii", + "ui.ribbon_menus_skip_pinned": "Lasă comenzile fixate în afara listelor derulante", "ui.ribbon_pin_footer": "Comenzile fixate apar pe bandă. Treceți cursorul peste un rând pentru a-l fixa sau defixa.", "ui.ribbon_workspace": "Spațiu de lucru", "ui.right": "Dreapta", @@ -4062,6 +4519,7 @@ "ui.select": "Selectează", "ui.selectOther": "Selectează altul…", "ui.selectTangentChain": "Selectează lanțul tangent", + "ui.select_all": "Selectează tot", "ui.selection": "Selecție", "ui.selection_modifiers": "Modificatori de selecție", "ui.selection_shift_add_only": "Shift+clic adaugă fără a elimina", @@ -4098,7 +4556,11 @@ "ui.sketch": "Schiță", "ui.sketch_dimension": "Cota schiței", "ui.sketch_grid": "Grila schiței", + "ui.sketch_intersect_geometry": "Geometrie", + "ui.sketch_intersect_projection_link": "Legătură de proiecție", + "ui.sketch_intersect_selection_filter": "Filtru de selecție", "ui.sketch_modifiers": "Modificatori de schiță", + "ui.sketch_project_geometry": "Geometrie", "ui.sketch_scale": "Scara schiței", "ui.sketches": "Schițe", "ui.slice": "Felie", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Gestionare", "ui.tab_sheet_metal": "Tablă", "ui.tab_view": "Vizualizare", + "ui.tab_weldments": "Sudură", "ui.tangent": "Tangent", "ui.taper_angle": "Unghi de conicitate", "ui.tapped": "Filetat", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Unghiul vârfului", "ui.to_object": "Până la obiect", "ui.tool_direction": "Direcția sculei", + "ui.toolbar_tabs_hidden": "Toate filele barei de instrumente sunt ascunse. Faceți clic pe + sau clic dreapta aici pentru a afișa una.", "ui.top": "Sus", "ui.top_diameter": "Diametru superior", "ui.top_radius": "Rază superioară", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Centrul vizualizării", "ui.viewport_status_clear": "Dezactivează {name}", "ui.viewport_status_clear_hint": "Dezactivează {name} · Esc", + "ui.viewport_status_edges": "{count} muchii", + "ui.viewport_status_from": "din {direction}", "ui.viewport_status_key": "Afișează legenda pentru {name}", "ui.viewport_status_minimum_radius": "Rază minimă", "ui.viewport_status_pairs": "{count} perechi", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Umbrit cu muchii", "ui.visual_style": "Stil vizual", "ui.visual_wireframe": "Cadru de sârmă", + "ui.wheel_zoom_speed": "Viteza de zoom a rotiței mouse-ului", "ui.width": "Lățime", "ui.window_system": "Sistem de ferestre", "ui.window_system_auto": "Automat", @@ -4240,7 +4707,9 @@ "update.button": "Actualizează", "update.button_version": "Actualizează la {version}", "update.check_automatically": "Verifică automat actualizările", + "update.check_for_updates": "Caută actualizări", "update.check_now": "Verifică acum", + "update.check_unavailable": "Căutarea actualizărilor nu este disponibilă pentru această instalare.", "update.checking": "Se verifică…", "update.copy": "Copiază", "update.dmg_opened": "Imaginea de disc este deschisă și verificată: trageți SolidCraft în Applications, înlocuind copia veche, apoi reporniți SolidCraft.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} este instalat. Reporniți pentru a-l folosi.", "update.installer_started": "Programul de instalare pornește când SolidCraft se închide.", "update.later": "Amintește-mi mai târziu", + "update.managed": "Actualizare disponibilă: actualizați SolidCraft în {manager}", "update.no_notes": "Această versiune nu are note.", "update.package_hint": "Această copie provine dintr-un {kind}. Descărcați noul pachet, apoi instalați-l cu:", "update.prefs_dev": "Aceasta este o versiune de dezvoltare ({version}): nu verifică actualizările.", "update.prefs_flatpak": "Flatpak actualizează această copie ({version}): rulați {command} sau folosiți centrul de software. SolidCraft nu verifică.", "update.prefs_label": "Actualizări", + "update.prefs_managed": "Aceasta este SolidCraft {version}, gestionat de {manager}. O verificare cere GitHub ultima versiune și nu trimite nimic altceva; actualizați SolidCraft în {manager}.", "update.prefs_note": "Aceasta este SolidCraft {version} ({kind}). O verificare cere GitHub ultima versiune și nu trimite nimic altceva.", "update.prefs_web": "Reîncărcați pagina pentru a obține cea mai recentă aplicație web. Dacă rețeaua nu este disponibilă sau actualizarea nu poate termina descărcarea la timp, se deschide versiunea salvată; o descărcare în curs continuă pentru următoarea reîncărcare.", + "update.refresh_checking": "Se caută cea mai nouă versiune…", + "update.refresh_fallback": "Nu s-a putut verifica dacă există o versiune mai nouă; se actualizează la {version}, găsită anterior.", + "update.refresh_selected": "Se actualizează la {version}.", "update.release_page": "Deschide pagina versiunii", "update.restart": "Repornește acum", "update.restart_later": "Mai târziu", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 500be156a1..a121b3934a 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -204,6 +204,7 @@ "Edges": "Рёбра", "Edit": "Изменить", "Edit Profile Sketch": "Редактировать эскиз профиля", + "Edit shortcuts": "Изменить сочетания клавиш", "Enable rule": "Включить правило", "Enabled": "Включено", "End Radius": "Радиус в конце", @@ -291,6 +292,7 @@ "Horiz./Vert.": "Гориз./верт.", "I-Beam": "Двутавр", "IMAGES": "ИЗОБРАЖЕНИЯ", + "If you are offline or GitHub does not open, use Copy report and keep the text to submit later.": "Если вы не в сети или GitHub не открывается, нажмите «Копировать отчёт» и сохраните текст, чтобы отправить его позже.", "Import material into design": "Импортировать материал в дизайн", "Import…": "Импорт…", "In This Design": "В этом дизайне", @@ -401,13 +403,16 @@ "Of": "Степень свободы", "On your computer": "На вашем компьютере", "Opacity": "Непрозрачность", + "Open GitHub": "Открыть GitHub", "Open GitHub draft": "Открыть черновик на GitHub", + "Open GitHub issue form": "Открыть форму Issue на GitHub", "Open bodies (no volume)": "Открытые тела (без объёма)", "Open library...": "Открыть библиотеку...", "Open local libraries in the desktop app. You can export this design's library here.": "Открывайте локальные библиотеки в настольном приложении. Здесь можно экспортировать библиотеку этого дизайна.", "Open surface — no volume": "Открытая поверхность — без объёма", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Открытая параметрическая 3D CAD на Rust. Лицензия MIT OR Apache-2.0.", "Opens your browser and copies the report text for you to paste into GitHub.": "Открывает браузер и копирует текст отчёта, чтобы вы вставили его на GitHub.", + "Opens your browser and copies the report text. Review and submit your report on GitHub.": "Открывает браузер и копирует текст отчёта. Проверьте и отправьте отчёт на GitHub.", "Opens your browser. Review and submit your report on GitHub.": "Открывает браузер. Проверьте и отправьте отчёт на GitHub.", "Operator": "Оператор", "Orbit (right drag)": "Вращение (перетаскивание правой кнопкой)", @@ -462,6 +467,7 @@ "Radius": "Радиус", "Radius Fit": "Подобрать радиус", "Ratio": "Отношение", + "Read-only list: shortcuts can't be changed here.": "Список только для чтения: сочетания клавиш здесь изменить нельзя.", "Reattach selected face": "Перепривязать выбранную грань", "Recover": "Восстановить", "Recover unsaved design?": "Восстановить несохранённый дизайн?", @@ -488,6 +494,7 @@ "Reopen the designs that were open when SolidCraft was last closed": "Открывать дизайны, которые были открыты при последнем закрытии SolidCraft", "Replace existing library file": "Заменить существующий файл библиотеки", "Report a bug — review a screenshot and open a GitHub draft": "Сообщить об ошибке — проверьте скриншот и откройте черновик на GitHub", + "Report copied. Keep this text to submit on GitHub when you are online.": "Отчёт скопирован. Сохраните этот текст, чтобы отправить его на GitHub, когда будете в сети.", "Report text copied. Paste it into GitHub and submit your report there.": "Текст отчёта скопирован. Вставьте его на GitHub и отправьте отчёт там.", "Reports are public. A GitHub account is required.": "Отчёты публичны. Нужна учётная запись GitHub.", "Reset Toolbar": "Сбросить панель инструментов", @@ -584,6 +591,7 @@ "Slider": "Ползунок", "Slot width": "Ширина паза", "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 закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.", "Spacing 2 must be a non-zero number": "Интервал 2 должен быть ненулевым числом", "Spacing must be a non-zero number": "Интервал должен быть ненулевым числом", @@ -643,6 +651,7 @@ "Third point": "Третья точка", "This child is shared. Deleting it removes it from every copy of its parent.": "Этот дочерний объект общий. Его удаление затронет все копии родителя.", "This child is shared. Position changes apply to every copy of its parent.": "Этот дочерний объект общий. Изменения положения применятся ко всем копиям родителя.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Этот компонент находится в подсборке, размещённой более одного раза: при выравнивании он перемещается в каждом размещении.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Эта грань не может обеспечить надёжную привязку. Выберите другую грань или явно используйте фиксированную плоскость.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Это изображение ещё не прикреплено на GitHub. Скопируйте или сохраните его, затем прикрепите на GitHub.", "Thread depth": "Глубина резьбы", @@ -783,6 +792,11 @@ "cam.roller": "Ролик", "cam.roller_pick": "Ролик (необязательно)", "cam.tip_pick": "Острие (необязательно)", + "camera.shot_pitch": "Тангаж", + "camera.shot_roll": "Крен", + "camera.shot_yaw": "Рыскание", + "canvas.decal_place_hint": "Щёлкните грань, чтобы разместить наклейку", + "canvas.place_hint": "Щёлкните плоскость или грань, чтобы разместить подложку", "choose a profile": "выберите профиль", "choose the cam's and the follower's joints": "выберите сопряжения кулачка и толкателя", "choose the joints to couple": "выберите сопряжения, которые нужно связать", @@ -790,12 +804,16 @@ "click sketch curves": "щёлкните кривые эскиза", "cmd.appearance_assign": "Внешний вид", "cmd.appearance_chain": "Цепочка внешних видов", + "cmd.appearance_copy": "Копировать внешний вид", + "cmd.appearance_favorite": "Избранный внешний вид", "cmd.appearance_library": "Библиотека внешних видов", "cmd.appearance_list": "Список внешних видов", "cmd.appearance_paint": "Нанести внешний вид", + "cmd.appearance_paste": "Вставить внешний вид", "cmd.appearance_pick_up": "Взять внешний вид", "cmd.appearance_replace": "Заменить внешний вид", "cmd.assembly_bom": "Спецификация", + "cmd.body.print_face": "Грань печати", "cmd.body_rename": "Переименовать тело", "cmd.browser_collapse_all": "Свернуть всё", "cmd.browser_expand_all": "Развернуть всё", @@ -803,6 +821,7 @@ "cmd.browser_move": "Переместить объекты браузера", "cmd.browser_order": "Упорядочить объекты браузера", "cmd.browser_rename_group": "Переименовать группу", + "cmd.browser_reparent": "Переместить в компонент", "cmd.browser_ungroup": "Разгруппировать", "cmd.browser_visibility": "Показать/Скрыть", "cmd.canvas_calibrate": "Калибровать подложку", @@ -829,7 +848,9 @@ "cmd.component_metadata": "Свойства детали", "cmd.component_metadata_set": "Изменить свойства детали", "cmd.component_move_bodies": "Переместить тела в компонент", + "cmd.component_move_features": "Переместить операции в компонент", "cmd.component_move_sketches": "Переместить эскизы в компонент", + "cmd.component_paste_from_design": "Вставить из другого дизайна", "cmd.component_paste_new": "Вставить как новый", "cmd.component_properties": "Физические свойства компонента", "cmd.component_relink": "Перепривязать источник", @@ -966,6 +987,7 @@ "cmd.motion_trace": "Траектория движения", "cmd.motion_trace_edit": "Изменить трассу движения", "cmd.motion_trace_keep": "Сохранить траекторию движения", + "cmd.occurrence_align": "Выровнять вхождение", "cmd.occurrence_copy": "Вставить (вхождение)", "cmd.occurrence_drag": "Перетащить вхождение", "cmd.occurrence_ground": "Закрепить", @@ -1018,10 +1040,16 @@ "cmd.render_library_surface_prepare": "Подготовить авторские карты поверхности", "cmd.render_library_surface_unbind": "Удалить привязку поверхности", "cmd.render_library_targets": "Найти цели библиотеки рендера", + "cmd.render_lights_duplicate": "Дублировать источник света рендера", + "cmd.render_lights_list": "Список источников света рендера", + "cmd.render_lights_mute": "Отключить источник света", + "cmd.render_lights_remove": "Удалить источник света рендера", + "cmd.render_lights_set": "Задать источник света рендера", "cmd.render_lights_solo": "Только этот источник света", "cmd.render_optics_bind": "Сохранить оптическую привязку", "cmd.render_optics_unbind": "Удалить оптическую привязку", "cmd.render_preflight": "Проверить запрос рендеринга", + "cmd.render_preflight_copy": "Копировать отчёт проверки рендера", "cmd.render_preset_apply": "Применить встроенную студию", "cmd.render_preset_resolve": "Проверить встроенную студию", "cmd.render_quality_prepare": "Проверить фиксированный бюджет сэмплов", @@ -1041,6 +1069,13 @@ "cmd.render_start": "Рендеринг изображения", "cmd.render_status": "Ход рендеринга", "cmd.select_clear": "Снять выделение", + "cmd.settings_profile_apply": "Применить профиль настроек", + "cmd.settings_profile_delete": "Удалить профиль настроек", + "cmd.settings_profile_export": "Экспортировать профиль настроек", + "cmd.settings_profile_import": "Импортировать профиль настроек", + "cmd.settings_profile_list": "Профили настроек", + "cmd.settings_profile_rename": "Переименовать профиль настроек", + "cmd.settings_profile_save": "Сохранить профиль настроек", "cmd.sheet_convert": "Преобразовать в листовой металл", "cmd.sheet_export_bend_table": "Экспортировать таблицу гибов", "cmd.sheet_export_dxf": "Экспортировать развёртку в DXF", @@ -1054,6 +1089,16 @@ "cmd.sheet_refold": "Повторный гиб", "cmd.sheet_rules": "Список правил листового металла", "cmd.sheet_unfold": "Разогнуть", + "cmd.sketch.construction_line": "Вспомогательная прямая", + "cmd.sketch.construction_line.angle": "Вспомогательная прямая под углом", + "cmd.sketch.construction_line.bisector": "Биссектриса", + "cmd.sketch.construction_line.horizontal": "Горизонтальная вспомогательная прямая", + "cmd.sketch.construction_line.parallel": "Параллельная вспомогательная прямая", + "cmd.sketch.construction_line.perpendicular": "Перпендикулярная вспомогательная прямая", + "cmd.sketch.construction_line.tangent_at": "Касательная в точке кривой", + "cmd.sketch.construction_line.tangent_external": "Касательная через внешнюю точку", + "cmd.sketch.construction_line.tangent_two": "Общая касательная", + "cmd.sketch.construction_line.vertical": "Вертикальная вспомогательная прямая", "cmd.sketch_arc3d": "3D-дуга", "cmd.sketch_arc_center_point": "Дуга из центра", "cmd.sketch_arc_tangent": "Касательная дуга", @@ -1086,6 +1131,8 @@ "cmd.sketch_drawing_bounds": "Размер чертежа", "cmd.sketch_edit": "Редактировать эскиз", "cmd.sketch_edit_text": "Редактировать текст", + "cmd.sketch_equation_curve": "Кривая по уравнению", + "cmd.sketch_equation_curve_edit": "Изменить кривую по уравнению", "cmd.sketch_export_dxf": "Сохранить как DXF", "cmd.sketch_finish_auto_constrain": "Завершить с автоограничениями", "cmd.sketch_fit_curves_to_section": "Подобрать кривые по сечению меша", @@ -1171,15 +1218,18 @@ "cmd.timeline_export_script": "Экспорт скрипта команд", "cmd.timeline_inspect": "Инспекция операции", "cmd.timeline_paste": "Вставить операции", + "cmd.timeline_play": "Воспроизвести историю", "cmd.timeline_redefine": "Изменить операцию", "cmd.timeline_rename": "Переименовать", "cmd.timeline_reorder": "Изменить порядок операции", "cmd.timeline_roll_to": "Переместить маркер истории", "cmd.timeline_rollback": "Сдвинуть маркер шкалы", + "cmd.timeline_stop": "Остановить воспроизведение истории", "cmd.timeline_suppress": "Подавить операцию", "cmd.view.render_browser": "Браузер рендера", "cmd.view.render_dock": "Переместить панель рендера", "cmd.view.render_focus": "Режим фокуса рендера", + "cmd.view.status_items": "Элементы состояния видового экрана", "cmd.view_ambient_occlusion": "Окружающее затенение", "cmd.view_angle_readout": "Углы вида", "cmd.view_apply": "Вернуть именованный вид", @@ -1197,8 +1247,11 @@ "cmd.view_list": "Список именованных видов", "cmd.view_navigate": "Навигация вида", "cmd.view_navigation_bar": "Панель навигации", + "cmd.view_next_tab": "Следующая вкладка", "cmd.view_normal_to": "По нормали к выделению", + "cmd.view_previous_tab": "Предыдущая вкладка", "cmd.view_rename": "Переименовать именованный вид", + "cmd.view_render_frame": "Направляющие кадра рендера", "cmd.view_right": "Вид справа", "cmd.view_save": "Новый именованный вид", "cmd.view_shots_strip": "Лента снимков", @@ -1209,10 +1262,14 @@ "color.copy": "Щёлкните, чтобы скопировать значения цвета", "color.edit": "Щёлкните, чтобы изменить цвет", "color.gamma": "Значения цвета показаны в гамма-пространстве 0–255", + "color.hex": "Hex", + "color.hex_help": "Введите #rrggbb или #rrggbbaa; при вводе шести цифр непрозрачность сохраняется", + "color.hex_hint": "#rrggbb", "color.hue": "Оттенок", "color.linear": "Значения цвета показаны в линейном пространстве 0–1", "color.saturation_value": "Насыщенность и яркость", "color.selected": "Выбранный цвет", + "config.column_filter_empty": "Нет подходящих столбцов", "contact.components_count": "Компонентов: {count}", "contact.enabled_label": "включено", "coupling.gear_1": "Шестерня 1", @@ -1326,7 +1383,9 @@ "help.material.assign": "Задаёт, из чего сделаны выбранные тела, например сталь, алюминий или ABS. Используйте, чтобы масса и вес соответствовали реальной детали.", "help.mit": "Лицензия MIT", "help.notice": "NOTICE", + "help.occurrence.move": "Перемещает деталь сборки только по степеням свободы, разрешённым её сопряжениями. Выберите грань детали, затем перетащите доступную стрелку или дугу.", "help.parameters.change": "Открывает таблицу именованных значений и выражений, управляющих размерами дизайна. Используйте, чтобы изменить размер в одном месте, а все операции, которые его используют, обновились следом.", + "help.sketch.equation_curve": "Рисует кривую по формулам: x(t) и y(t) либо y = f(x) на заданном интервале. В формулах можно использовать параметры, и кривая следует за ними. Используйте для синусоиды, спирали или эвольвенты зуба шестерни.", "help.solid.align": "Перемещает выбранные тела так, чтобы точка, грань или плоскость на них совпала с точкой, гранью или плоскостью в другом месте. Используйте, чтобы приставить деталь к другой, не измеряя смещение.", "help.solid.chamfer": "Срезает выбранные рёбра плоской фаской на заданное расстояние. Используйте, чтобы притупить острые кромки или облегчить вход штифта или винта.", "help.solid.combine": "Объединяет тела, вырезает их из целевого тела или оставляет только общий для них объём; тела-инструменты можно сохранить. Используйте, чтобы слить детали, вырезать одной формой другую или найти, где два тела пересекаются.", @@ -1352,6 +1411,7 @@ "help.third_party": "Лицензии сторонних компонентов", "help.timeline.compute_all": "Перестраивает все операции в истории с самого начала. Используйте, чтобы обновить модель и увидеть, какие операции завершаются с ошибкой.", "help.timeline.delete": "Удаляет выбранные операции из истории; при удалении эскиза также удаляются операции, построенные на нём. Используйте, чтобы убрать шаг, который больше не нужен.", + "help.timeline.play": "Воспроизводит оставшиеся операции по одной с паузой в полсекунды после каждой перестройки. Чтобы остановить, запустите эту команду снова или нажмите Esc; «Перейти в конец» переходит мгновенно.", "hole_presets.confirm_delete": "Подтвердить удаление", "hole_presets.delete": "Удалить", "hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.", @@ -1452,6 +1512,21 @@ "joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.", "joint.rigid_group_one_component": "Эти тела ({count}) все находятся в одном компоненте (или в самом дизайне): сначала используйте {command}.", "joint.rigid_group_select_components": "Выберите два или более компонентов.", + "joint_move.blocked_hint": "Проверьте тип сопряжения (например, «Жёсткое»), закрепление, ограничения сопряжения и значения, заданные параметрами.", + "joint_move.blocked_title": "Эту деталь нельзя переместить", + "joint_move.coarse": "Шаг поворота вблизи оси (°)", + "joint_move.distance": "Расстояние от начала", + "joint_move.done": "Готово", + "joint_move.fine": "Шаг поворота вдали от оси (°)", + "joint_move.fixed": "У этой детали нет подвижной оси сопряжения.", + "joint_move.help": "Перемещение — перетащите стрелку или кольцо. Esc отменяет текущее перетаскивание.", + "joint_move.legacy_hint": "Отключите, чтобы использовать прежнюю панель перемещения.", + "joint_move.precision": "Для точного поворота потяните за пределы пунктирной направляющей.", + "joint_move.rotation": "Поворот от начала", + "joint_move.select": "Выберите грань или одну деталь сборки, затем выберите «Переместить».", + "joint_move.settings": "Перемещение сборки", + "joint_move.step": "Шаг", + "joint_move.use_handles": "Использовать манипуляторы движения сопряжений для перемещения сборки", "keymap.command_id": "ID команды", "keymap.empty": "Нет подходящих команд", "keymap.use_anyway": "Всё равно использовать здесь {key}", @@ -1546,6 +1621,8 @@ "nav.maya.help": "Alt+перетаскивание левой кнопкой вращает, Alt+средняя кнопка панорамирует, Alt+правая кнопка меняет масштаб; перетаскивание средней кнопкой тоже панорамирует", "nav.openscad.help": "Перетаскивание левой кнопкой по пустому месту вращает, Shift+левая выделяет рамкой, перетаскивание геометрии эскиза изменяет её; правая или средняя кнопка панорамирует, Shift+правая или Shift+средняя меняет масштаб", "nav.perspective.sketch": "При редактировании эскиза используется ортогональный вид. Завершите эскиз, чтобы включить перспективу.", + "nav.right_drag_orbits.help": "Выкл.: перетаскивание правой кнопкой не двигает камеру, а правая кнопка только открывает контекстное меню. Выбор назначения кнопок мыши SolidWorks отключает этот параметр.", + "nav.right_drag_orbits.label": "Вращение перетаскиванием правой кнопкой", "nav.solidworks.help": "Перетаскивание средней кнопкой вращает, Ctrl+средняя панорамирует, Shift+средняя меняет масштаб, перетаскивание правой кнопкой вращает", "nav.tinkercad.help": "Перетаскивание правой кнопкой вращает, средней или Shift+правой — панорамирует", "nav.touchpad.help": "Удерживайте Shift и двигайте указатель для панорамирования; удерживайте Alt/Option и двигайте для вращения. Нажимать кнопку мыши не нужно. Вертикальная прокрутка изменяет масштаб.", @@ -1567,6 +1644,9 @@ "navbar.custom.customise": "Настроить панель навигации…", "navbar.custom.design": "Дизайн", "navbar.custom.duplicate": "Этот элемент уже есть на панели.", + "navbar.custom.edge": "Край", + "navbar.custom.edge_bottom": "Снизу", + "navbar.custom.edge_top": "Сверху", "navbar.custom.fade": "Приглушена", "navbar.custom.full": "Уберите один элемент, чтобы добавить другой.", "navbar.custom.hidden_design": "Скрыто в режиме «Дизайн»", @@ -1673,6 +1753,7 @@ "parts.dimension.thickness": "Толщина", "parts.dimension.width": "Ширина", "parts.family.bearing": "Радиальный шарикоподшипник (габаритная модель)", + "parts.family.button_head_cap_screw": "Винт с полукруглой головкой и внутренним шестигранником", "parts.family.dowel_pin": "Цилиндрический штифт", "parts.family.hex_bolt": "Болт / винт с шестигранной головкой", "parts.family.hex_nut": "Шестигранная гайка", @@ -1727,6 +1808,11 @@ "prefs.bodies_folders_several": "Только если тел несколько", "prefs.bottom": "Снизу", "prefs.browser": "Браузер", + "prefs.browser_eye_left": "Глаз рядом со стрелкой свёртывания", + "prefs.browser_eye_left_note": "Строки браузера показывают глаз показа/скрытия рядом со стрелкой свёртывания, как раньше, а не в столбце на правом конце строк.", + "prefs.browser_reveal_on_hover": "Появляется при наведении", + "prefs.browser_show_note": "«Показан» оставляет браузер на экране. «Появляется при наведении» прячет его, пока указатель не окажется на его месте или на тонкой вкладке у его левого края; он остаётся открытым, пока указатель находится внутри него или открыто его меню.", + "prefs.browser_shown": "Показан", "prefs.built_in": "Встроенный", "prefs.button_left": "Перетаскивание левой кнопкой", "prefs.button_middle": "Перетаскивание средней кнопкой", @@ -1774,9 +1860,13 @@ "prefs.graphics_card_restart": "Вступит в силу после перезапуска SolidCraft. WGPU_ADAPTER_NAME переопределяет этот выбор.", "prefs.graphics_note": "Выбирайте между скоростью и плавностью изображения. Каждый вариант показывает свой эффект на образце.", "prefs.graphics_presets_note": "Пресеты задают сразу все настройки ниже.", + "prefs.hide_constraint_glyphs_zoomed_out": "Скрывать знаки ограничений при уменьшении масштаба", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Знаки ограничений исчезают из вида, когда эскиз уменьшен настолько, что они загромождали бы его. Выкл.: при любом масштабе они сохраняют фиксированный размер на экране.", "prefs.history": "История", "prefs.history_origin_bodies": "Показывать начало координат и тела сверху", "prefs.history_origin_bodies_note": "История слева или под браузером начинается с папки «Тела» активного компонента и начала координат, с их глазками и меню, как в браузере. История внизу не меняется.", + "prefs.history_play_button": "Кнопка «Воспроизвести историю» в элементах управления историей", + "prefs.history_play_button_note": "Помещает «Воспроизвести историю» между «На один шаг назад» и «На один шаг вперёд» в элементах управления историей. Её также можно показать или скрыть, щёлкнув элементы управления правой кнопкой.", "prefs.history_sketches": "Эскизы", "prefs.history_sketches_in_order": "По порядку", "prefs.history_sketches_note": "Если для эскизов выбрано «Под своей операцией», История слева показывает каждый эскиз на один уровень глубже, под первой операцией, построенной по нему, когда она достаточно широкая для названий. Шкала внизу всегда сохраняет порядок создания.", @@ -1794,6 +1884,8 @@ "prefs.layout_note": "«Компактная» прикрепляет панели к краям окна. Прозрачность браузера доступна в режиме «Плавающая». Сторону ассистента можно изменить на странице «Ассистент».", "prefs.left": "Слева", "prefs.left_click": "Щелчок левой кнопкой", + "prefs.line_drag_draws_one": "Нажатие и перетаскивание рисует одну линию", + "prefs.line_drag_draws_one_note": "В инструменте «Линия» нажатие, перетаскивание и отпускание рисуют одну линию от места нажатия до места отпускания, и на этом она заканчивается. Щелчки по-прежнему рисуют цепочку. Выкл.: перетаскивание ставит точку там, где его отпустили, как и щелчок.", "prefs.linked_designs": "Связанные дизайны", "prefs.live_input": "Ввод в реальном времени", "prefs.live_preview": "Предпросмотр в реальном времени", @@ -1807,6 +1899,9 @@ "prefs.max_file_size_step": "Файлы STEP и импорта", "prefs.max_file_size_web": "Веб-приложение открывает файлы STEP и импорта до 512 МБ и дизайны до 256 МБ: вкладка браузера не вмещает более крупные файлы. В настольном приложении эти пределы можно увеличить.", "prefs.megabytes": "МБ", + "prefs.menu_grid": "Крупные значки", + "prefs.menu_list": "Список", + "prefs.menus": "Меню", "prefs.minutes": "мин.", "prefs.minutes_suffix": " min", "prefs.mouse": "Мышь и тачпад", @@ -1826,14 +1921,38 @@ "prefs.origin_plane_names": "Имена плоскостей начала координат", "prefs.origin_plane_names_note": "Имена в браузере, диалогах и списках. {top} — это XY, {front} — XZ, {right} — YZ, ось Z направлена вверх. Команды, скрипты и сохранённые дизайны по-прежнему используют XY, XZ и YZ.", "prefs.overall_speed": "Общая скорость", + "prefs.perspective_fov": "Угол обзора", "prefs.placement": "Размещение", "prefs.prefer_free_sketch_points": "Предпочитать подвижные точки эскиза, когда точки перекрываются", "prefs.preview_choices": "Предпросмотр вашего выбора", + "prefs.print_face_highlight": "Подсветка грани печати", + "prefs.print_face_highlight_tip": "Насколько сильно грань печати тела окрашивается и обводится в виде. 0 % — без подсветки.", + "prefs.profile_active": "Активный профиль", + "prefs.profile_applied": "Профиль применён.", + "prefs.profile_changed": "Настройки изменились после сохранения профиля. «Сохранить» запоминает их в профиле.", + "prefs.profile_deleted": "Профиль удалён.", + "prefs.profile_export": "Экспорт…", + "prefs.profile_exported": "Профиль экспортирован.", + "prefs.profile_imported": "Профиль импортирован. Выберите его в списке «Активный профиль», чтобы переключиться на него.", + "prefs.profile_name_hint": "Имя профиля, например «Ноутбук»", + "prefs.profile_rename": "Переименовать", + "prefs.profile_renamed": "Профиль переименован.", + "prefs.profile_save": "Сохранить", + "prefs.profile_saved": "Текущие настройки сохранены в профиле.", + "prefs.profile_skipped": "Профиль применён; настроек, сохранивших текущее значение: {n} (см. журнал).", + "prefs.profiles": "Профили настроек", + "prefs.profiles_note": "Сочетания клавиш, панель инструментов, навигация, радиальное меню и настройки интерфейса хранятся вместе под одним именем и переключаются сразу.", + "prefs.project_options_dialog": "Диалог параметров проецирования", + "prefs.project_options_dialog_note": "Вкл.: перед проецированием выберите «Геометрия» или «Тела» и «Связь проекции». Выкл.: прежний порядок работы с прямым выбором.", "prefs.quality_default": "по умолчанию", "prefs.quality_fastest": "быстрее всего", "prefs.quality_slowest": "медленнее всего", "prefs.quality_smoother": "глаже", + "prefs.render_previous_geometry_budget": "Использовать прежний бюджет геометрии предпросмотра рендера", + "prefs.render_previous_geometry_budget_help": "Вернуть прежний фиксированный предел геометрии (512 МиБ на 64-разрядном настольном компьютере, 128 МиБ на остальных устройствах) вместо бюджета, зависящего от устройства.", "prefs.reports_saved": "Сохранено: {n}", + "prefs.ribbon_reveal": "Показывать результаты поиска на ленте", + "prefs.ribbon_reveal_note": "Наведение на результат в «Поиске команд» показывает, где команда находится на ленте: её вкладку, кнопку или строку в раскрывающемся списке панели. Команда, запущенная из поиска, мигает там, а выполняемая команда из раскрывающегося списка отмечает свою панель.", "prefs.right_click": "Правый щелчок", "prefs.saving": "Автосохранение и восстановление", "prefs.scale_note": "Также Ctrl + / Ctrl − в окне. Кнопки — 28 px при 100%.", @@ -1842,16 +1961,25 @@ "prefs.search_empty": "Нет подходящих настроек", "prefs.select": "Выделить", "prefs.select_one_item": "Выбрать один элемент", + "prefs.select_other_hidden": "Выбирать скрытую геометрию командой «Выбрать другое»", + "prefs.select_other_hidden_help": "Вкл.: учитывать скрытые тела, эскизы и перекрытые рёбра рядом с указателем. Выкл.: использовать исходный список видимой геометрии под указателем.", "prefs.selecting": "Выделение", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift только добавляет; Ctrl / Cmd переключает", "prefs.shift_add_note": "Вкл.: Shift добавляет, Ctrl / Cmd переключает. Выкл. (по умолчанию): переключают обе клавиши.", "prefs.shortcuts": "Сочетания клавиш", + "prefs.single_surface_analysis": "Использовать один анализ поверхности", + "prefs.single_surface_analysis_help": "Если выключено, затенение поверхности и «Непрерывность» могут быть включены одновременно. Включение этого параметра оставляет последний запущенный или изменённый анализ.", + "prefs.sketch_align_points": "Выравнивать по другим точкам эскиза при рисовании", + "prefs.sketch_align_points_note": "При рисовании точка выравнивается по горизонтали с одной точкой эскиза и по вертикали с другой одновременно, с пунктирной направляющей, и щелчок сохраняет оба выравнивания. Выкл.: только с предыдущей точкой, по одной оси за раз.", + "prefs.sketch_construction_lines": "Расширенные инструменты вспомогательных прямых", "prefs.sketch_exit_view": "По окончании эскиза", "prefs.sketch_exit_view.keep": "Оставить текущий вид", "prefs.sketch_exit_view.previous": "Вернуться к прежнему виду", "prefs.sketch_exit_view.unless_rotated": "Вернуться, если вид не повёрнут", "prefs.sketch_exit_view_note": "Куда переходит вид после «Завершить эскиз», «Отмена» или двойного Esc.", + "prefs.sketch_recent_snap": "Автопривязка к недавним центрам и серединам", + "prefs.sketch_recent_snap_note": "При рисовании курсор привязывается к месту пересечения направляющих двух последних центров окружностей или середин прямых отрезков. Выкл.: эти направляющие остаются только визуальными, а курсор не смещается.", "prefs.sketch_selection": "Эскиз и выделение", "prefs.sketch_selection_heading": "Эскиз и выделение", "prefs.sketch_selection_note": "Как работает привязка в эскизах и как щелчки выделяют объекты.", @@ -1888,7 +2016,14 @@ "prefs.timeline_quarter_hour": "−15", "prefs.toggle_item": "Добавить элемент или убрать его снова", "prefs.toolbar": "Панель инструментов", + "prefs.toolbar_tabs": "Вкладки панели инструментов", "prefs.top": "Сверху", + "prefs.trackpad_zoom_modifier": "Масштабировать прокруткой двумя пальцами при удержании", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Удерживайте только эту клавишу, чтобы масштабировать в точке указателя вертикальной прокруткой. «Выкл.» сохраняет прежнее поведение прокрутки.", + "prefs.trackpad_zoom_modifier.off": "Выкл.", "prefs.translucent": "Полупрозрачный", "prefs.under_browser": "Под браузером", "prefs.under_cursor": "Под курсором", @@ -1898,9 +2033,16 @@ "prefs.view_confirm_corner": "OK и Отмена в углу вида", "prefs.view_confirm_corner_note": "Пока открыт диалог или эскиз, галочка и крестик в правом верхнем углу вида делают то же, что OK и Отмена в диалоге, либо завершают и отменяют эскиз. Навигатор видов сдвигается вниз под ними.", "prefs.viewport": "Видовой экран", + "prefs.viewport_analysis_status": "Показывать состояние анализа над видовым экраном", + "prefs.viewport_analysis_status_help": "Показывает активные анализы над видовым экраном, открывает их легенды из каждого элемента, а клавиша Esc выключает самый новый анализ. Выключите, чтобы вернуть легенды в углах и прежнее поведение Esc.", + "prefs.viewport_background": "Фон", + "prefs.viewport_background_colour": "Цвет", + "prefs.viewport_background_theme": "Градиент темы", "prefs.wheel": "Колесо", "prefs.wheel_trackpad": "Колесо и тачпад", "prefs.workspace": "Рабочее пространство", + "prefs.zoom_stop_at_model": "Останавливать масштабирование у модели", + "prefs.zoom_stop_at_model_tip": "3D-мышь и приложение устройства приближаются к поверхности в середине вида; масштабирование перетаскиванием удерживает точку, с которой началось перетаскивание. Выкл.: масштабирование может проходить сквозь модель. Колесо и щипок используют отдельную настройку «Масштаб к курсору»", "prefs.zoom_toward_cursor": "Масштаб — к курсору", "properties.title": "Свойства: {name}", "recovery.days_ago": "{count} дн. назад", @@ -1989,6 +2131,7 @@ "render.appearance_editor.weight": "Вес", "render.appearance_physics_separate": "Внешний вид не задаёт плотность, тепловые данные и другие инженерные свойства.", "render.appearances": "Внешние виды", + "render.assign_help": "Щёлкните, чтобы применить к выделенному; перетащите для предпросмотра и отпустите на объекте; либо щёлкните без выделения, чтобы начать наносить.", "render.availability_native": "Встроенная композиция; проверьте перед применением", "render.availability_projection": "Поддерживаемое проецирование; см. охват и исключения", "render.availability_reference": "Справочные данные; автоматически не назначаются", @@ -2027,6 +2170,7 @@ "render.bloom_threshold": "Порог линейных ярких участков", "render.browse_capabilities": "Просмотреть полный каталог возможностей", "render.browse_environments": "Обзор оригинальных HDR-окружений", + "render.browse_looks": "Обзор…", "render.browse_native_scenes": "Обзор встроенных студий", "render.browse_physical": "Просмотреть физические эталоны", "render.browse_quality": "Просмотреть спецификации качества", @@ -2047,6 +2191,82 @@ "render.category_paint": "Краска", "render.category_plastic": "Пластик", "render.category_wood": "Дерево", + "render.children.absolute_floor": "Абсолютный порог", + "render.children.adaptive": "Адаптивный", + "render.children.add_look_reason": "Добавьте стиль, чтобы настроить его включение и силу.", + "render.children.all_shots": "Все кадры", + "render.children.bloom": "Свечение", + "render.children.check": "Проверить", + "render.children.check_now": "Проверить сейчас", + "render.children.chromatic": "Хроматическое смещение", + "render.children.compact": "Компактный", + "render.children.contrast": "Контраст", + "render.children.copy_image": "Копировать", + "render.children.copy_job": "Копировать JSON задания", + "render.children.copy_request": "Копировать JSON запроса", + "render.children.coverage_error": "Ошибка покрытия", + "render.children.cpu": "Трассировка лучей на CPU", + "render.children.denoise": "Уменьшить шум в готовом изображении", + "render.children.depth": "Глубина пути", + "render.children.distortion": "Дисторсия", + "render.children.edge_samples": "Сэмплы краёв", + "render.children.effects_banner": "Эти эффекты требуют трассировки путей на GPU. Открытие этого раздела никогда не меняет метод.", + "render.children.enable_adaptive": "Включите независимые пробные проходы, чтобы изменять адаптивные пределы.", + "render.children.enable_bloom": "Включите свечение, чтобы изменять его силу, порог и радиус.", + "render.children.finishing": "Финишная обработка", + "render.children.flare": "Блик", + "render.children.fresnel": "Страты Френеля", + "render.children.ghosts": "Кольца паразитных бликов", + "render.children.glass": "Перенос света в стекле", + "render.children.go_to": "Перейти к: {editor}", + "render.children.grain": "Зерно", + "render.children.halo": "Ореол", + "render.children.hdr": "HDR", + "render.children.keep_master": "Сохранять необработанный линейный мастер", + "render.children.last_result": "Последний: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Эффекты объектива", + "render.children.look_summary": "{tone} · стилей: {count}", + "render.children.looks": "Творческие стили", + "render.children.memory": "Память", + "render.children.method": "Метод", + "render.children.minimum": "Минимум", + "render.children.needs_cpu": "Требуется трассировка лучей на CPU. Открытие этого раздела никогда не меняет метод.", + "render.children.nested": "Сертифицированные вложенные диэлектрики", + "render.children.not_checked": "Не проверено", + "render.children.path": "Трассировка путей", + "render.children.pilots": "Пробные сэмплы", + "render.children.preview": "Данные предпросмотра", + "render.children.progressive_reason": "Выберите «Прогрессивный», чтобы изменять число сэмплов и начальное значение.", + "render.children.radius": "Радиус", + "render.children.reflect_depth": "Глубина отражений", + "render.children.reflect_samples": "Сэмплы отражений", + "render.children.regrade": "Перекорректировать", + "render.children.relative_error": "Относительная ошибка", + "render.children.render": "Рендер", + "render.children.render_shot": "Рендер кадра", + "render.children.renderer": "Рендерер", + "render.children.renderers": "Рендереры", + "render.children.request": "Этот запрос", + "render.children.reset_finishing": "Сбросить финишную обработку", + "render.children.review_quality": "Проверить спецификацию качества…", + "render.children.rgb_gain": "Усиление RGB", + "render.children.samples": "Сэмплы", + "render.children.sampling": "Сэмплирование", + "render.children.saturation": "Насыщенность", + "render.children.scene_summary": "Тел: {bodies} · треугольников: {triangles}", + "render.children.seed": "Начальное значение", + "render.children.sharpening": "Резкость", + "render.children.single_image": "Одно изображение", + "render.children.soft_focus": "Мягкий фокус", + "render.children.streak": "Полоса", + "render.children.strength": "Сила", + "render.children.studio": "Студия", + "render.children.threshold": "Порог", + "render.children.tone": "Тон", + "render.children.under_hood": "Под капотом", + "render.children.vignette": "Виньетирование", + "render.children.workstation": "Рабочая станция", + "render.choose_look": "Сначала выберите внешний вид в разделе «Внешние виды»", "render.chromatic_offset": "Хроматическое смещение краёв", "render.clear_stage": "Удалить сохранённую площадку", "render.copy_request": "Копировать JSON", @@ -2146,6 +2366,86 @@ "render.effects_reset_lens": "Сбросить эффекты объектива", "render.effects_seed": "Начальное значение узора", "render.emitted_axis": "Ось излучения", + "render.env_advanced": "Дополнительно", + "render.env_backdrop": "Задник", + "render.env_backdrop_pending": "Видимость окружения и размытие фона пока не поддерживаются рендерером.", + "render.env_blur": "Размытие", + "render.env_change": "Изменить", + "render.env_choose_source": "Выберите окружение в разделе «Студии и окружения», прежде чем изменять освещение.", + "render.env_clear": "Очистить", + "render.env_colour": "Цвет", + "render.env_environments": "Окружения", + "render.env_ground": "Пол", + "render.env_ground_pending": "Физический пол принимает тени как конечная диффузная поверхность. Улавливание только теней и отражения на полу пока не поддерживаются.", + "render.env_intensity": "Интенсивность", + "render.env_invalid_edit": "Недопустимое изменение окружения.", + "render.env_physical_ground": "Физический пол", + "render.env_preview_help": "Иллюстративный образец-сфера; это не предпросмотр выбранного окружения.", + "render.env_reflection": "Отражение", + "render.env_ring_help": "Перетащите кольцо, чтобы повернуть освещение. Удерживайте Shift для привязки с шагом 15°.", + "render.env_rotate": "Повернуть", + "render.env_set": "Задать окружение рендера", + "render.env_shadow_catcher": "Улавливатель теней", + "render.env_show_environment": "Показывать окружение", + "render.env_source": "Источник", + "render.env_stage_details": "Для изменения пола нужны трассировка лучей и одна панель пола. Чтобы изменять другие панели площадки, используйте раздел «Сцена».", + "render.env_studios": "Студии", + "render.environment.albedo": "Альбедо", + "render.environment.atmosphere": "Атмосфера", + "render.environment.backdrop_note": "Задник виден только камере. Он никогда не освещает, не отбрасывает тени и не отражается, а при прозрачном выводе отклоняется.", + "render.environment.blur_pending": "Размытие недоступно: в рендерере нет настройки размытия задника.", + "render.environment.builtin": "Встроенный HDR", + "render.environment.check": "Проверить файл", + "render.environment.checked": "Файл HDR проверен. При сохранении встраиваются эти байты.", + "render.environment.choose": "Выбрать HDR…", + "render.environment.constant": "Постоянный", + "render.environment.cyclorama": "Циклорама", + "render.environment.embedded": "Сохранён внутри этого дизайна", + "render.environment.end": "Конец", + "render.environment.extinction": "Экстинкция", + "render.environment.file": "Файл", + "render.environment.fit": "Подогнать и сохранить пол", + "render.environment.floor_wall": "Пол и стена", + "render.environment.gap": "Зазор", + "render.environment.gradient": "Градиент", + "render.environment.gradient_gpu": "Градиентному заднику нужна трассировка путей на GPU.", + "render.environment.ground": "Пол и площадка", + "render.environment.hdr": "HDR", + "render.environment.hdr_file": "Файл HDR", + "render.environment.horizon": "Горизонт", + "render.environment.horizontal": "Горизонтальный", + "render.environment.keep": "Оставить", + "render.environment.lighting_samples": "Сэмплы освещения", + "render.environment.margin": "Запас по размеру", + "render.environment.max": "Максимальные границы", + "render.environment.medium": "Участвующая среда", + "render.environment.min": "Минимальные границы", + "render.environment.mode": "Режим", + "render.environment.needs_gpu": "Требуется трассировка путей на GPU.", + "render.environment.none": "Нет", + "render.environment.orientation": "Ориентация и сила", + "render.environment.phase": "Фазовая функция", + "render.environment.primaries": "Считать неуказанные основные цвета линейным sRGB", + "render.environment.primaries_gpu": "Интерпретация неуказанных основных цветов требует трассировки путей на GPU.", + "render.environment.radial": "Радиальный", + "render.environment.radial_only": "Центр и радиус применяются в радиальном режиме.", + "render.environment.reflectance": "Коэффициент отражения", + "render.environment.remove": "Удалить площадку", + "render.environment.remove_confirm": "Удалить сохранённую площадку?", + "render.environment.remove_note": "Удаляет из этого кадра пассивные панели ({count}). Задник камеры остаётся.", + "render.environment.replace": "Подтвердить замену существующей площадки", + "render.environment.rotation_hdr": "Для поворота при трассировке лучей на CPU нужен источник HDR.", + "render.environment.samples_cpu": "Сэмплы освещения задаются бюджетом трассировки путей на GPU; для этой настройки нужна трассировка лучей на CPU.", + "render.environment.saved_panels": "Сохранённые панели: {count}", + "render.environment.sky_ground": "Земля", + "render.environment.sky_note": "Оригинальный градиент неба для освещения; это не калиброванная атмосфера.", + "render.environment.source_raytrace": "Источникам окружения нужна трассировка лучей на CPU или GPU.", + "render.environment.stage_raytrace": "Физическим площадкам нужна трассировка лучей на CPU или GPU.", + "render.environment.start": "Начало", + "render.environment.vertical": "Вертикальный", + "render.environment.world": "Мир", + "render.environment.world_pending": "Задник «Мир» недоступен: для видимости окружения камерой пока нет сохраняемой настройки.", + "render.environment.zenith": "Зенит", "render.environment_tiles_require_raytrace": "Для окружений требуется трассировка лучей на CPU или GPU. Выберите её в редакторе «Вывод».", "render.error_area_samples": "area_samples должно быть 1, 4, 16 или 64", "render.error_binding_allocation": "не удалось зарезервировать привязку материала", @@ -2218,8 +2518,33 @@ "render.flare_strength": "Сила бликов от ярких участков", "render.flare_threshold": "Порог блика в линейном пространстве сцены", "render.fold_browser": "Свернуть браузер", + "render.frame_aspect": "Соотношение сторон", + "render.frame_centre_mark": "Метка центра", + "render.frame_copy_limit": "Нет свободного имени для копии кадра", + "render.frame_copy_name": "{name} копия {number}", + "render.frame_custom": "Свой", + "render.frame_distance_unit": " × размер", + "render.frame_duplicate": "Дублировать", + "render.frame_exposure": "Экспозиция", + "render.frame_fov": "Угол обзора", + "render.frame_guides": "Направляющие", + "render.frame_invalid_aspect": "Недопустимый пресет соотношения сторон", + "render.frame_lens": "Угол обзора {fov}° · ≈ {mm} мм", + "render.frame_name": "Имя", + "render.frame_output_size": "Размер вывода", + "render.frame_photo": "3:2 · Фото", + "render.frame_portrait": "4:5 · Портрет", "render.frame_presets": "Пресеты кадрирования", "render.frame_presets_pending": "Появится в редакторе кадров", + "render.frame_projection": "Проекция", + "render.frame_render_shot": "Рендерить этот кадр", + "render.frame_rule_thirds": "Правило третей", + "render.frame_safe_area": "Безопасная зона", + "render.frame_shot_title": "Кадр · {name}", + "render.frame_square": "1:1 · Квадрат", + "render.frame_tall": "9:16 · Вертикальный", + "render.frame_thirds": "Трети", + "render.frame_wide": "16:9 · Широкоформатный", "render.gpu_depth": "Максимальная глубина путей", "render.gpu_options_help": "Сэмплы, глубина, начальное значение и экспозиция сохраняются в этой настройке. Сохраняется только активный метод изображения.", "render.gpu_path_failed": "Трассировщик путей на GPU недоступен", @@ -2247,6 +2572,7 @@ "render.heading_results": "РЕЗУЛЬТАТЫ", "render.heading_shots": "КАДРЫ", "render.hide_shots_label": "Скрыть ленту кадров", + "render.hover_look": "{object} · {look} · щёлкните, чтобы выбрать", "render.image_advanced": "Расширенные настройки GPU", "render.image_cancelled": "Рендеринг отменён", "render.image_cancelling": "Отмена", @@ -2393,17 +2719,95 @@ "render.library_uniform_ack": "Использовать однородное приближение без пространственных и расширенных эффектов", "render.library_usable": "Исполняемые проекции", "render.library_users": "Используется в", + "render.light_aim": "Навести на", "render.light_anchor_pending": "Изменение системы координат света пока не поддерживается.", + "render.light_azimuth": "Азимут", + "render.light_backend_limit": "Чтобы изменять источники света, выберите трассировку лучей на CPU или явную студию на GPU. Источники света устаревшей студии на GPU остаются только для чтения.", + "render.light_behaviour": "ПОВЕДЕНИЕ", + "render.light_default_fill": "Заполняющий свет", + "render.light_default_key": "Основной свет", + "render.light_default_number": "Источник света {number}", + "render.light_default_rim": "Контровой свет", + "render.light_delete": "Удалить", + "render.light_distance": "Расстояние", + "render.light_duplicate": "Дублировать", + "render.light_elevation": "Угол места", + "render.light_gpu_punctual": "Точечные источники и прожекторы доступны при трассировке лучей на CPU.", + "render.light_hard_shadow": "У этого источника света жёсткие тени; мягкость излучателя недоступна.", + "render.light_intensity": "Интенсивность", + "render.light_missing": "Этого источника света больше нет. Выберите источник света в браузере.", + "render.light_place_highlight": "Разместить по блику", + "render.light_place_pending": "Для размещения по блику нужен выбор источников света на видовом экране; это пока не поддерживается.", + "render.light_position": "ПОЛОЖЕНИЕ", + "render.light_preview_help": "Интерактивный просмотр без теней; площадные источники света приближаются по одному образцу в центре. Рендер использует полную модель света.", + "render.light_product_centre": "Центр изделия", + "render.light_size": "Размер", + "render.light_softness": "Мягкость", + "render.light_temperature": "Цветовая температура", + "render.light_temperature_help": "Приблизительный оттенок чёрного тела; сохраняется как линейный RGB.", + "render.light_type": "Тип", + "render.light_type_area": "Площадной", + "render.light_type_point": "Точечный", + "render.light_type_spot": "Прожектор", + "render.light_type_sun": "Солнце", + "render.light_view_only": "Режимы «только этот источник» и «отключить» действуют на этот вид и его рендеры; в дизайне они никогда не сохраняются.", + "render.light_world_origin": "Начало мировых координат", + "render.lighting_aim_point": "Точка наведения", + "render.lighting_apply": "Применить пресет", + "render.lighting_area_samples": "Сэмплы площадных источников", + "render.lighting_budget": "Бюджет", + "render.lighting_budget_help": "Количества только для чтения. Чтобы изменить их, добавьте, продублируйте или удалите источники света; пределы задаёт метод.", + "render.lighting_colour": "Цвет", + "render.lighting_consent": "Заменить направленные источники света этим пресетом", + "render.lighting_custom": "Пользовательские источники света", + "render.lighting_custom_help": "Если выбрать «Пользовательские», пресет станет набором редактируемых источников света, по одной строке в дереве на каждый.", + "render.lighting_direction": "Направление X Y Z", + "render.lighting_direction_help": "Или введите направление на источник света (X Y Z).", + "render.lighting_distance_unit": " × размер", + "render.lighting_edge_u": "Вектор стороны U", + "render.lighting_edge_v": "Вектор стороны V", + "render.lighting_falloff": "Затухание", + "render.lighting_falloff_help": "Интенсивность указана для опорного расстояния.", + "render.lighting_height": "Высота V", + "render.lighting_minimum": "Минимум", + "render.lighting_mute": "Отключить этот источник света", + "render.lighting_point": "Точечный", + "render.lighting_preset": "Студийный пресет", + "render.lighting_preset_cpu": "Направленные студийные пресеты требуют трассировки лучей на CPU. Освещение на GPU использует явные источники «Солнце» и «Площадной».", + "render.lighting_preset_row": "Пресет", + "render.lighting_radiance": "Яркость", + "render.lighting_reference": "Опорное расстояние", + "render.lighting_review_help": "Заменяет список направленных источников света. Точечные и площадные источники, окружение, тени, камера и все остальные параметры кадра сохраняются.", + "render.lighting_review_title": "Проверка пресета освещения", + "render.lighting_samples_cpu": "Сэмплы площадных источников применяются при трассировке лучей на CPU.", + "render.lighting_samples_need_area": "Добавьте площадной источник света на CPU, чтобы задать сэмплы площадных источников.", + "render.lighting_samples_reason": "Сэмплов площадных источников должно быть 1, 4, 16 или 64.", + "render.lighting_shadow_cpu": "Переключатель теней требует трассировки лучей на CPU. Трассировка путей на GPU всегда отбрасывает тени.", + "render.lighting_shadows": "Отбрасывать тени", + "render.lighting_shadows_section": "Тени и сэмплинг", + "render.lighting_shape": "Форма", + "render.lighting_solo": "Только этот источник света", + "render.lighting_source": "Источник", + "render.lighting_source_section": "Источник света", + "render.lighting_spot_preserved": "Этот точечный источник света сохраняет заданный конус прожектора, пока не изменён его тип.", + "render.lighting_sun_distance": "Направление солнца не зависит от расстояния и точки наведения.", + "render.lighting_unsupported": "не поддерживается", + "render.lighting_used": "{used} из {limit}", + "render.lighting_width": "Ширина U", + "render.lighting_world_position": "В мире X Y Z", "render.lights_fixed_to": "Источники света привязаны к", "render.linear_help": "Сохраняет 32-битный вещественный мастер RGBA до шумоподавления, экспозиции, тональной компрессии, фильтров и задника. Линейный sRGB/D65; цвет с предумножением на покрытие. Дополнительная память выделяется только явно. Без сжатия и дополнительных проходов.", "render.linear_retain": "Сохранять необработанный линейный EXR", "render.linear_unavailable": "У выбранного готового кадра нет сохранённого EXR. Включите сохранение до рендера: старый PNG не позволяет восстановить необработанную энергетическую яркость.", "render.look_add": "Добавить цветовой стиль", + "render.look_applied": "Применено: {name}", "render.look_bleach": "В духе пропуска отбеливания", "render.look_cool": "Холодный", "render.look_cyanotype": "В духе цианотипии", "render.look_faded": "Выцветший отпечаток", + "render.look_material": "Материал", "render.look_mono": "Монохром", + "render.look_pasted": "Стиль вставлен", "render.look_sepia": "Сепия", "render.look_strength": "Сила стиля", "render.look_teal_orange": "Бирюзовый / оранжевый", @@ -2476,10 +2880,15 @@ "render.optics_saved": "Сохранённые оптические цели", "render.optics_saved_help": "Точная идентичность определения, операции и вхождения сохраняется при повторном открытии без сети. Отсутствующие, заменённые и скрытые цели отклоняются; инженерные материалы не меняются. Сохраните или отмените числовые правки, прежде чем удалять сохранённую цель.", "render.outer_half_angle": "Внешний половинный угол", + "render.output_check_failed": "Проверка · ошибка", + "render.output_copy_report": "Копировать отчёт", "render.output_format_status": "PNG — точное готовое изображение для показа. Необязательный EXR с GPU сохраняет необработанную линейную яркость сцены в FLOAT32 до применения фильтров показа. Экспорт никогда не подставляет кэш просмотра или другое задание.", + "render.output_grading": "Цветокоррекция", "render.output_lock_ratio": "Зафиксировать соотношение сторон", "render.output_over_budget": "Запрошенное изображение превышает выбранные пределы ресурсов. Явно выберите подходящий размер или профиль; автоматического понижения не происходит.", "render.output_ratio_refused": "Связанный размер превышает предел для этого метода. Прежний размер сохранён; разблокируйте соотношение или укажите меньшее значение.", + "render.output_run_check": "Выполнить проверку", + "render.output_size_limit": "{method}: до {dimension} px по стороне и {pixels} пикселей. Наибольший подходящий размер: {width} × {height}.", "render.output_swap_orientation": "Поменять местами ширину и высоту", "render.output_tools_inspector": "Измените размер и формат в инспекторе «Вывод».", "render.page_aging": "Старение и загрязнение", @@ -2494,6 +2903,7 @@ "render.page_output": "Вывод", "render.page_scene": "Сцена", "render.paint_label": "Краска", + "render.painting_with": "Нанесение: {name}", "render.passive_stage": "Пол и пассивная площадка", "render.passive_stage_help": "Конечные пассивные панели относятся только к отрендеренным изображениям. Они не добавляют тел CAD и массы. Размещение фиксируется после применения; пересечение с габаритами изделия отклоняется. На CPU диффузное переотражённое освещение не поддерживается.", "render.perspective": "Перспективная камера", @@ -2515,6 +2925,7 @@ "render.pick_help": "Выберите «Тело», «Грань» или «Компонент». Щелчок по пустому месту снимает выбор; Shift или Ctrl добавляют или переключают. Подсветка отображается только в интерактивном виде изделия.", "render.pick_label": "Выбор", "render.pick_up_label": "Взять образец", + "render.pick_up_prompt": "Щёлкните объект, чтобы взять его стиль", "render.pick_waiting": "Выбор изделия пока не поддерживается.", "render.post_contrast": "Контраст", "render.post_gain_b": "Усиление синего", @@ -2546,13 +2957,17 @@ "render.quality_review": "Проверить бюджет сэмплов", "render.queue_pending": "В этом интерфейсе нет автоматической очереди рендера и средства рендера анимации. Готовые неподвижные изображения по-прежнему можно экспортировать отдельно.", "render.radial_distortion": "Радиальная дисторсия", + "render.recent_looks": "Недавние стили", "render.regrade_action": "Перекорректировать необработанный мастер (без шумоподавления)", "render.regrade_help": "Создаёт отдельный PNG из выбранного готового необработанного мастера с этими параметрами презентации. Не трассирует лучи, не меняет зафиксированные размер, камеру и источники света и не перезаписывает EXR. Шумоподавление не применяется повторно: необработанные мастера не хранят его вспомогательные буферы. Производный файл можно экспортировать теми же элементами управления задачей.", "render.regrade_label": "Перекорректировать", "render.reload_settings": "Загрузить сохранённые настройки", + "render.remove_override": "Удалить переопределение", + "render.replace_all": "Заменить все", "render.replace_ground": "Подтвердить замену существующей площадки", "render.replace_label": "Заменить", "render.replace_saved_surface": "Заменить существующую сохранённую привязку для этой цели", + "render.replace_with": "Заменить на", "render.restore_camera": "Восстановить сохранённую камеру", "render.result_actual_help": "Один пиксель изображения на один пиксель экрана.", "render.result_actual_size": "100%", @@ -2603,14 +3018,28 @@ "render.saved_surface_remove": "Удалить сохранённую привязку", "render.saved_surfaces": "Сохранённые привязки поверхностей", "render.saved_surfaces_help": "Закреплённые встроенные карты следуют за выбранным вхождением в локальных миллиметрах. Заменённая или отсутствующая цель — это ошибка, а не автоматическое переназначение.", + "render.scene_all_shots": "Все кадры ({count})", "render.scene_apply": "Применить проверенную студию", + "render.scene_batch_reason": "Для режима «Все кадры» нужна очередь рендера сохранённых кадров, которая пока недоступна.", + "render.scene_check_now": "Проверить сейчас", + "render.scene_check_passed": "Проверка пройдена · замечаний: {notes}", + "render.scene_check_stale": "Проверка устарела · проверьте снова", + "render.scene_check_unchecked": "Ещё не проверено · замечаний: 0", "render.scene_consent": "Подтвердить замену освещения, окружения, экспозиции и площадки", + "render.scene_light_summary": "{environment} · источников света: {lights}", + "render.scene_look_summary": "стилей: {looks} · переопределений: {overrides}", + "render.scene_open_check": "Открыть проверку", "render.scene_preserved_fields": "Камера, сэмплинг, размеры, оптические привязки, геометрия CAD и инженерные материалы сохраняются.", "render.scene_preserves_design": "Составляет освещение и пассивную площадку для видимого объекта. Геометрия, инженерные материалы и камера остаются отдельными.", + "render.scene_ready": "Готово к рендеру", + "render.scene_reminder": "Работа со сценой не затрагивает дизайн. Геометрия, инженерные свойства и CAD никогда не изменяются из «Рендера».", + "render.scene_reminders": "Напоминания", + "render.scene_renderer_ready": "{renderer} готов", "render.scene_replaces": "Заменяет", "render.scene_review": "Определить и проверить", "render.scene_review_help": "Перед применением просмотрите полную встроенную композицию. Непривязанные расширенные спецификации SCENE не подставляются.", "render.scene_scope_details": "Охват композиции", + "render.scene_stages": "Этапы", "render.search_appearances": "Поиск по внешним видам: {count}", "render.setup_legacy_environment": "Эта более старая настройка использует устаревшие поворот или интенсивность окружения. Эти значения сохраняются; преобразуйте их явно перед сохранением полной задачи рендера изображения.", "render.setup_name": "Имя набора настроек", @@ -2623,7 +3052,22 @@ "render.shell_tab_camera": "КАМЕРА РЕНДЕРА", "render.shell_tab_lighting": "ОСВЕЩЕНИЕ", "render.shell_tab_scene": "СЦЕНА РЕНДЕРА", + "render.shot_aperture": "Апертура", + "render.shot_distance": "Расстояние", + "render.shot_dof": "Глубина резкости", "render.shot_duplicate": "Дублировать сохранённый кадр", + "render.shot_edit_output": "Изменить в «Выводе»", + "render.shot_exposure_reason": "Для экспозиции нужна трассировка лучей на CPU или трассировка путей на GPU.", + "render.shot_exposure_section": "Экспозиция", + "render.shot_focus_distance": "Расстояние фокусировки", + "render.shot_fov_reason": "Для угла обзора нужна перспективная проекция.", + "render.shot_frame_subject": "Кадрировать объект", + "render.shot_invalid_camera": "Значения камеры должны быть конечными и находиться в допустимых пределах.", + "render.shot_lens": "Объектив", + "render.shot_lens_enable_reason": "Включите глубину резкости, чтобы изменять апертуру и расстояние фокусировки.", + "render.shot_lens_gpu_reason": "Для глубины резкости нужна трассировка путей на GPU.", + "render.shot_lens_perspective_reason": "Для глубины резкости нужна перспективная проекция.", + "render.shot_look_selection": "Взгляд на выделенное", "render.shot_manager": "Сохранённые кадры", "render.shot_new_name": "Имя нового кадра", "render.shot_remove": "Удалить сохранённый кадр", @@ -2631,6 +3075,9 @@ "render.shot_remove_title": "Удалить сохранённый кадр?", "render.shot_rename": "Переименовать сохранённый кадр", "render.shot_saved_only": "Сначала сохраните текущий черновик. Дублирование и переименование сохраняют точные сохранённые параметры и ссылки на ресурсы.", + "render.shot_selection_reason": "Выберите тело или грань, на которые нужно навести камеру.", + "render.shot_size": "Размер", + "render.shot_target": "Цель", "render.shots_camera_changed": "Камера изменилась после сохранения этого кадра", "render.shots_name_help": "Введите уникальное имя для текущего кадра.", "render.shots_no_result": "Рендера ещё нет", @@ -2650,11 +3097,99 @@ "render.soft_focus": "Мягкий фокус", "render.spot_cone": "Конус прожектора", "render.stage_frame": "Кадрирование", + "render.stage_layout.appearance_note": "Щёлкните, чтобы применить к выделению.", + "render.stage_layout.studio_note": "Щёлкните, чтобы проверить перед применением.", "render.stage_light": "Свет", "render.stage_look": "Стиль", "render.stage_orthographic": "Ортографическая", "render.stage_output": "Вывод", "render.stage_panel_count": "Сохранённые панели", + "render.stage_panels.acknowledge": "Я принимаю замену и ограничения", + "render.stage_panels.appearance_note": "Щёлкните, чтобы применить к выделению. Перетащите на модель. Если ничего не выделено, щелчок включает режим «Краска».", + "render.stage_panels.apply": "Применить", + "render.stage_panels.apply_studio": "Применить студию", + "render.stage_panels.badge_blocked": "заблокировано", + "render.stage_panels.badge_native": "встроено", + "render.stage_panels.badge_projection": "проекция", + "render.stage_panels.badge_reference": "эталон", + "render.stage_panels.badge_specification": "только спец.", + "render.stage_panels.candidate_summary": "Кандидаты: значений из источников — {count}. Марку и отделку нужно проверить.", + "render.stage_panels.captured_bounds": "Использовать границы снятой сцены", + "render.stage_panels.centre": "Центр", + "render.stage_panels.depth": "глубина", + "render.stage_panels.environments": "Окружения", + "render.stage_panels.extent": "Габариты", + "render.stage_panels.fit_save": "Подогнать и сохранить", + "render.stage_panels.gpu_required": "Требуется трассировка путей на GPU", + "render.stage_panels.keep": "Оставить", + "render.stage_panels.kept": "Сохраняется", + "render.stage_panels.kept_fields": "Камера, сэмплинг, размеры, оптические привязки, геометрия CAD и инженерные свойства.", + "render.stage_panels.kept_quality": "Метод, камера, освещение, окружение, площадка, оптика, экспозиция и тональная компрессия.", + "render.stage_panels.mine": "Мои", + "render.stage_panels.mine_pending": "Сохранение личных студий пока не поддерживается.", + "render.stage_panels.omits": "Не включено", + "render.stage_panels.paint_pending": "«Краска» и назначение перетаскиванием пока не поддерживаются. Выберите цель, чтобы применить стиль.", + "render.stage_panels.partial_reference": "Кандидаты: только значения из источников; проверьте марку, состояние и источники. Неизвестные значения остаются неизвестными.", + "render.stage_panels.plane": "Плоскость", + "render.stage_panels.prepare_plan": "Подготовить план создания", + "render.stage_panels.projects": "Проекты", + "render.stage_panels.quality_ack": "Я принимаю фиксированный бюджет и его исключения", + "render.stage_panels.quality_omissions": "Адаптивная остановка и целевые ошибки, отдельная глубина пропускания, правила русской рулетки, необработанный линейный вывод, мастер EXR и сертификация сходимости.", + "render.stage_panels.reference": "Эталон", + "render.stage_panels.references": "Эталоны", + "render.stage_panels.remove_optics": "Удалить оптику", + "render.stage_panels.remove_optics_help": "Удаляет сохранённую оптику из этого кадра. Исходная геометрия сохраняется.", + "render.stage_panels.remove_optics_title": "Удалить сохранённую оптику?", + "render.stage_panels.remove_shot": "Удалить кадр", + "render.stage_panels.remove_stage": "Удалить площадку", + "render.stage_panels.remove_stage_help": "Удаляет сохранённые пассивные панели из этого кадра. Задник камеры остаётся.", + "render.stage_panels.remove_stage_title": "Удалить сохранённую площадку?", + "render.stage_panels.repeat": "Повтор", + "render.stage_panels.replaces": "Заменяет", + "render.stage_panels.replaces_environment": "Окружение", + "render.stage_panels.replaces_maps": "Временные карты выбранного тела; сохранённая оптика может конфликтовать.", + "render.stage_panels.replaces_physical": "Ничего. Этот план только для чтения.", + "render.stage_panels.replaces_quality": "Размеры изображения, фиксированные сэмплы, глубина путей, начальное значение и шумоподавление.", + "render.stage_panels.replaces_rig": "Освещение, окружение и площадка", + "render.stage_panels.replaces_saved_maps": "Сохранённые карты поверхности выбранного тела; временные переопределения могут конфликтовать.", + "render.stage_panels.replaces_stage": "Сохранённые пол и пассивные панели площадки", + "render.stage_panels.replaces_studio": "Освещение, окружение, экспозиция и площадка", + "render.stage_panels.replaces_uniform": "Непрозрачный внешний вид выбранного тела. Пространственные карты приближаются однородными значениями.", + "render.stage_panels.review_environment": "Проверить окружение", + "render.stage_panels.review_maps": "Проверить карты для GPU", + "render.stage_panels.review_physical": "План физического эталона", + "render.stage_panels.review_quality": "Использовать фиксированный бюджет сэмплов", + "render.stage_panels.review_rig": "Подогнать схему освещения", + "render.stage_panels.review_saved_maps": "Проверить сохранённые карты", + "render.stage_panels.review_stage": "Проверить пол и площадку", + "render.stage_panels.review_studio": "Проверить встроенную студию", + "render.stage_panels.review_uniform": "Проверить однородный внешний вид", + "render.stage_panels.rig_summary": "Авторская схема освещения · излучателей: {emitters}, карт: {cards}", + "render.stage_panels.rigs": "Схемы", + "render.stage_panels.save_maps": "Сохранить карты в этом кадре", + "render.stage_panels.scope": "Охват", + "render.stage_panels.search_studios": "Поиск студий", + "render.stage_panels.search_surfaces": "Поиск поверхностей и эталонов", + "render.stage_panels.source_note": "Авторская исходная карта базового цвета. Это не измеренный материал.", + "render.stage_panels.specifications": "Спецификации · только чтение", + "render.stage_panels.studio_ack": "Подтвердить замену освещения, окружения, экспозиции и площадки", + "render.stage_panels.studio_note": "Щёлкните, чтобы проверить. Применение заменяет освещение, окружение и площадку только после вашего подтверждения. Источники света остаются редактируемыми.", + "render.stage_panels.studios": "Студии", + "render.stage_panels.subtitle_environment": "Оригинальное HDR-окружение · вся сцена", + "render.stage_panels.subtitle_maps": "Авторские пространственные карты · временный предпросмотр", + "render.stage_panels.subtitle_physical": "План только для чтения · материал не создан и не назначен", + "render.stage_panels.subtitle_quality": "Фиксированный бюджет GPU · будущие требования не включены", + "render.stage_panels.subtitle_rig": "Авторская схема освещения · излучатели и пассивные карты", + "render.stage_panels.subtitle_saved_maps": "Авторские карты · сохраняются в этом кадре", + "render.stage_panels.subtitle_stage": "Физический пол и площадка · подогнаны под изделие", + "render.stage_panels.subtitle_studio": "Встроенная студия · применяется ко всей сцене", + "render.stage_panels.subtitle_uniform": "Однородное приближение · не измеренный материал", + "render.stage_panels.surfaces": "Поверхности", + "render.stage_panels.surfaces_references": "Поверхности и эталоны", + "render.stage_panels.target": "Цель", + "render.stage_panels.use_budget": "Использовать этот бюджет", + "render.stage_panels.use_maps": "Использовать карты для GPU", + "render.stage_panels.use_uniform": "Использовать однородный вид", "render.stage_preview_note": "Встроенный просмотр · прямой свет", "render.strip_label": "Лента", "render.studio_add_area": "Добавить площадной источник света", @@ -2757,6 +3292,13 @@ "render.wait_pick_up": "пока не поддерживается: выбор изделия и взятие образца внешнего вида", "render.wait_replace": "пока не поддерживается: замена всех использований внешнего вида", "render.wait_solo": "пока не поддерживается: управление видимостью источников света и режимом «только этот источник»", + "render.wear_applies_to": "Применяется к", + "render.wear_layer_count": "Слоёв: {count}", + "render.wear_layer_one": "{count} слой", + "render.wear_layers": "Слои", + "render.wear_no_layers": "Слоёв нет", + "render.wear_selected_layer": "Выбранный слой", + "render.wear_title": "Износ", "sample.bracket.about": "Базовый фланец с краевыми фланцами и кромкой по правилу листового металла 2 мм", "sample.bracket.name": "Кронштейн из листового металла", "sample.enclosure.about": "Тонкостенный бокс с выступом, бобышками под винты, опорой и защёлкой", @@ -2790,6 +3332,7 @@ "select an axis": "выберите ось", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "сначала выберите ось, прямое или круговое ребро либо грань цилиндра, конуса или тора", "select bodies first": "сначала выберите тела", + "select components first": "сначала выберите компоненты", "select corner points, or the two lines of one corner": "выберите угловые точки или две линии одного угла", "select one corner point, or the two lines of one corner": "выберите одну угловую точку или две линии одного угла", "select sheet edges first": "сначала выберите рёбра листа", @@ -2808,6 +3351,7 @@ "settings.double_click_components": "Двойной щелчок по компоненту активирует его или изменяет связанный компонент на месте", "settings.frame_rate_details_note": "Добавляет график времени последних кадров с отрисованными поверх него треугольниками, гистограмму времени кадров и то, что нарисовал GPU. Щёлкните частоту кадров в виде, чтобы показать или скрыть их.", "settings.frame_rate_note": "Показывает кадры в секунду в правом нижнем углу вида.", + "settings.insert_lists_open_designs": "«Вставить дизайн» сначала показывает дизайны, открытые на других вкладках", "settings.measure_axis_distances": "Расстояния X/Y/Z", "settings.measure_axis_distances_help": "Показывать размеры вдоль мировых осей вместе с общим расстоянием, пока открыто окно измерения.", "settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft", @@ -2832,6 +3376,10 @@ "shortcut.unpin": "Открепить", "sketch is missing": "эскиз отсутствует", "sketch.degrees_of_freedom": "Степеней свободы: {count}", + "sketch.dimension_added_driven": "Эскиз уже определяет этот размер, поэтому он добавлен как справочный (ведомый) размер", + "sketch.dimension_applied": "Размер применён", + "sketch.dimension_applied_defined": "Размер применён; параметр `{name}` определён", + "sketch.dimension_applied_renamed": "Размер применён; параметру присвоено имя `{name}`", "sketch.dof_left": "Осталось степеней свободы: {count}", "sketch.dof_left_one": "Осталась {count} степень свободы", "sketch.draw_curves": "Рисовать как {linetype}", @@ -2866,6 +3414,8 @@ "sketch_text.use_font_file": "Использовать файл шрифта {path}", "solid": "тело", "status.editing": "Изменение: {name}", + "status.navigation": "Назначение кнопок мыши: {mapping}", + "status.navigation_tip": "Как мышь двигает вид. Щёлкните, чтобы выбрать другое назначение", "status.under_constrained": "Недоопределён (степеней свободы: {count})", "status.under_constrained_one": "Недоопределён (степеней свободы: {count})", "status.units_tip": "Единицы дизайна. Щёлкните, чтобы изменить", @@ -2899,8 +3449,10 @@ "timeline.delete_folders_tip": "Оставить их операции и вложенные папки в истории", "timeline.delete_folders_with_features_tip": "Удалить папки вместе со всеми операциями в них и вложенными папками", "timeline.delete_many": "Удалить выбранные операции: {count}", + "timeline.edited_rolled_back": "{name}: изменено, но находится после маркера истории, поэтому не строится — переместите маркер дальше, чтобы увидеть результат", "timeline.feature_error": "Ошибка: {message}", "timeline.feature_hint": "Двойной щелчок — изменить · перетащите — изменить порядок · правый щелчок — другие действия", + "timeline.feature_rolled_back": "Откат: после маркера истории, поэтому не строится", "timeline.feature_warning": "Предупреждение: {message}", "timeline.group": "Новая папка операций", "timeline.move_many": "Переместить объекты шкалы", @@ -2980,6 +3532,10 @@ "ui.3d_mouse_offer": "Калибровка 3D-мыши исправляет перепутанные или перевёрнутые оси и настраивает скорость и ощущения.", "ui.3d_mouse_offer_never": "Больше не показывать", "ui.3d_mouse_offer_swap": "Переключить на Hidraw (прямой драйвер)", + "ui.3d_mouse_orbit": "Орбита", + "ui.3d_mouse_orbit_classic": "Классическая", + "ui.3d_mouse_orbit_tip": "Следящая: 3D-мышь поворачивает вид вокруг той части модели, которая находилась в центре вида в начале поворота; она отмечена бледным шариком. Классическая: вокруг центра вида", + "ui.3d_mouse_orbit_traced": "Следящая", "ui.3d_mouse_reading": "Показания", "ui.3d_mouse_reset_axes": "Сбросить назначение осей", "ui.3d_mouse_reset_view": "Сбросить вид", @@ -3016,6 +3572,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.", "ui.3d_mouse_switch_cap": "Калибровать как возврат в центр", "ui.3d_mouse_switch_trackball": "Калибровать как трекбол", + "ui.3d_mouse_use_direct": "Использовать прямой драйвер", + "ui.3d_mouse_use_direct_tip": "Читать 3D-мышь напрямую (hidraw), а не через spacenavd.", "ui.3d_mouse_view_axis": "Ось вида", "ui.3d_mouse_view_off": "Выкл.", "ui.3d_mouse_view_off_tip": "Профиль драйвера 3D-мыши отключает эту ось, поэтому её движение не двигает вид. Если выбрать здесь движение другой оси, они поменяются местами, и это движение перестанет двигать вид.", @@ -3307,6 +3865,9 @@ "ui.blocked": "Заблокировано", "ui.bodies": "Тела", "ui.bodies_all_if_none": "Тела (все, если не выбраны)", + "ui.body_kind_mesh": "Тело-меш", + "ui.body_kind_solid": "Твёрдое тело", + "ui.body_kind_surface": "Поверхностное тело", "ui.bottom": "Снизу", "ui.box": "Бокс", "ui.break": "Разорвать", @@ -3327,6 +3888,7 @@ "ui.circle": "Окружность", "ui.circular_pattern": "Круговой массив", "ui.clear": "Очистить", + "ui.clear_print_face": "Сбросить грань печати", "ui.clear_recent": "Очистить недавние", "ui.clear_the_selection": "Снять выделение", "ui.click_selects_whole_bodies": "Щелчок выделяет тела целиком", @@ -3361,6 +3923,15 @@ "ui.constant": "Постоянный", "ui.constraints_conflict": "Ограничения конфликтуют", "ui.construction": "Вспомогательная", + "ui.construction.angle_hint": "Вспомогательная прямая: укажите точку, затем введите угол или задайте направление", + "ui.construction.bisector_hint": "Биссектриса: выберите две пересекающиеся прямые, затем нужное решение", + "ui.construction.common_hint": "Общая касательная: выберите две окружности/дуги, затем нужное решение", + "ui.construction.contact_hint": "Касательная: выберите окружность/дугу или открытый сплайн/конику, затем точку касания (для сплайна/коники — конец)", + "ui.construction.external_hint": "Касательная: выберите окружность или дугу, внешнюю точку, затем нужное решение", + "ui.construction.free_hint": "Вспомогательная прямая: укажите две точки; правая кнопка — начать заново", + "ui.construction.point_hint": "Вспомогательная прямая: укажите точку прохождения", + "ui.construction.reference_hint": "Выберите прямую, затем точку; правая кнопка — сменить исходную прямую", + "ui.construction.ribbon_label": "Всп. линии", "ui.context_menu": "Контекстное меню", "ui.continuity_g0": "G0, излом", "ui.continuity_g1": "G1, касательная", @@ -3401,6 +3972,7 @@ "ui.design_properties.part_number": "Номер детали", "ui.design_properties.project": "Проект", "ui.design_properties.revision": "Редакция", + "ui.design_saved": "Сохранено: {path}", "ui.diameter": "Диаметр", "ui.dimension": "Размер", "ui.direct_interaction": "Прямое взаимодействие", @@ -3451,6 +4023,14 @@ "ui.end_scale": "Масштаб в конце", "ui.entities": "Объекты", "ui.equal": "Равенство", + "ui.equation_end": "Конец", + "ui.equation_explicit": "Явная", + "ui.equation_form": "Форма", + "ui.equation_note": "Переменная — обычное число: sin(t) принимает её в радианах. Результат без единицы измерения задаётся в единицах дизайна.", + "ui.equation_parametric": "Параметрическая", + "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.exportDxf": "Экспорт DXF…", @@ -3516,6 +4096,7 @@ "ui.heading_design": "ДИЗАЙН", "ui.heading_draft": "УКЛОН", "ui.heading_edit": "ИЗМЕНИТЬ", + "ui.heading_equation_curve": "КРИВАЯ ПО УРАВНЕНИЮ", "ui.heading_extrude": "ВЫДАВЛИВАНИЕ", "ui.heading_fillet": "СКРУГЛЕНИЕ", "ui.heading_finish_editing": "ЗАВЕРШИТЬ ИЗМЕНЕНИЕ", @@ -3589,6 +4170,10 @@ "ui.history_marker_tip": "Маркер истории: перетащите, чтобы откатить модель; новые операции добавляются сюда", "ui.history_names": "Имена", "ui.history_play": "Воспроизвести до конца", + "ui.history_play_button_hide": "Скрыть кнопку воспроизведения", + "ui.history_play_button_show": "Показать кнопку воспроизведения", + "ui.history_play_finish_action": "Завершите текущее действие перед воспроизведением истории", + "ui.history_play_roll_back": "Переместите маркер истории назад перед воспроизведением", "ui.history_show_names": "Показать имена", "ui.history_sketch_listed_under": "Показан под операцией {feature}", "ui.history_title": "История", @@ -3603,6 +4188,10 @@ "ui.import_ellipsis": "Импорт…", "ui.infinite": "Бесконечная", "ui.insert": "Вставить", + "ui.insert_design.browse": "Обзор…", + "ui.insert_design.open_designs": "Открытые дизайны", + "ui.insert_design.saved_file": "В {name} есть несохранённые изменения, поэтому вставлен его сохранённый файл. Сохраните его, затем выполните «Обновить связи», чтобы подтянуть изменения.", + "ui.insert_design.unsaved": "Несохранённые изменения", "ui.inspect": "Инспекция", "ui.intersect": "Пересечение", "ui.isolate": "Изолировать", @@ -3737,12 +4326,29 @@ "ui.polygon": "Многоугольник", "ui.preferences": "Настройки", "ui.press_a_key": "нажмите клавишу…", + "ui.print_face_cleared": "{body}: грань печати сброшена", + "ui.print_face_input": "Грань печати", + "ui.print_face_one_body": "Грань печати относится к одному телу: отправьте это тело отдельно, чтобы изменить её", + "ui.print_face_pick": "Выберите плоскую грань твёрдого тела, чтобы сделать её гранью печати", + "ui.print_face_set": "{body}: грань печати задана; «Отправить в слайсер» кладёт тело на неё", "ui.profile": "Профиль", "ui.profile_plane": "Плоскость профиля", "ui.profiles": "Профили", "ui.project": "Проецировать", "ui.project_face_edges_sketch": "Проецировать рёбра граней в новые эскизы", "ui.properties": "Свойства", + "ui.properties.copy_matrix": "Копировать матрицу", + "ui.properties.first_moments": "Статические моменты", + "ui.properties.inertia": "Момент инерции", + "ui.properties.inertia_note": "Относительно центра масс, в мировых осях (g·{unit}²)", + "ui.properties.inertia_unavailable": "Недоступно: сетка тела не замкнута.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Измерение…", + "ui.properties.measuring_failed": "Измерение остановлено, не закончив работу.", + "ui.properties.principal_axis": "Главная ось {number}", + "ui.properties.principal_moments": "Главные моменты", "ui.pull_direction": "Направление вытягивания", "ui.quality": "Качество", "ui.quantity": "Количество", @@ -3786,6 +4392,7 @@ "ui.revolve": "Вращение", "ui.ribbon_exit": "Выход", "ui.ribbon_labels": "Показывать текст под значками ленты", + "ui.ribbon_menus_skip_pinned": "Не включать закреплённые команды в выпадающие меню", "ui.ribbon_pin_footer": "Закреплённые команды отображаются на ленте. Наведите курсор на строку, чтобы закрепить или открепить её.", "ui.ribbon_workspace": "Рабочая область", "ui.right": "Справа", @@ -3840,7 +4447,11 @@ "ui.sketch": "Эскиз", "ui.sketch_dimension": "Размер эскиза", "ui.sketch_grid": "Сетка эскиза", + "ui.sketch_intersect_geometry": "Геометрия", + "ui.sketch_intersect_projection_link": "Связь проекции", + "ui.sketch_intersect_selection_filter": "Фильтр выделения", "ui.sketch_modifiers": "Модификаторы эскиза", + "ui.sketch_project_geometry": "Геометрия", "ui.sketch_scale": "Масштаб эскиза", "ui.sketches": "Эскизы", "ui.slice": "Срез", @@ -3891,6 +4502,7 @@ "ui.tab_manage": "Управление", "ui.tab_sheet_metal": "Листовой Металл", "ui.tab_view": "Вид", + "ui.tab_weldments": "Сварка", "ui.tangent": "Касание", "ui.taper_angle": "Угол наклона", "ui.tapped": "С резьбой", @@ -3906,6 +4518,7 @@ "ui.tip_angle": "Угол при вершине", "ui.to_object": "До объекта", "ui.tool_direction": "Направление инструмента", + "ui.toolbar_tabs_hidden": "Все вкладки панели инструментов скрыты. Щёлкните + или щёлкните здесь правой кнопкой, чтобы показать вкладку.", "ui.top": "Сверху", "ui.top_diameter": "Верхний диаметр", "ui.top_radius": "Верхний радиус", @@ -3941,11 +4554,20 @@ "ui.view_angles_roll": "Крен", "ui.view_angles_yaw": "Рыскание", "ui.view_centre": "Центр вида", + "ui.viewport_status_clear": "Выключить {name}", + "ui.viewport_status_clear_hint": "Выключить {name} · Esc", + "ui.viewport_status_edges": "Рёбер: {count}", + "ui.viewport_status_from": "со стороны {direction}", + "ui.viewport_status_key": "Показать легенду для {name}", + "ui.viewport_status_minimum_radius": "Минимальный радиус", + "ui.viewport_status_pairs": "Пар: {count}", + "ui.viewport_status_stripes": "Полос: {count}", "ui.viewport_theme_follow": "Следовать теме", "ui.visual_shaded_hidden_edges": "Заполненный с невидимыми рёбрами", "ui.visual_shaded_with_edges": "Заполненный с рёбрами", "ui.visual_style": "Стиль отображения", "ui.visual_wireframe": "Каркас", + "ui.wheel_zoom_speed": "Скорость масштабирования колесом мыши", "ui.width": "Ширина", "ui.window_system": "Оконная система", "ui.window_system_auto": "Автоматически", @@ -3976,7 +4598,9 @@ "update.button": "Обновить", "update.button_version": "Обновить до {version}", "update.check_automatically": "Проверять обновления автоматически", + "update.check_for_updates": "Проверить обновления", "update.check_now": "Проверить сейчас", + "update.check_unavailable": "Проверка обновлений недоступна для этой установки.", "update.checking": "Проверка…", "update.copy": "Копировать", "update.dmg_opened": "Образ диска открыт и проверен: перетащите SolidCraft в «Программы», заменив старую копию, затем перезапустите SolidCraft.", @@ -3997,6 +4621,9 @@ "update.prefs_label": "Обновления", "update.prefs_note": "Это SolidCraft {version} ({kind}). При проверке у GitHub запрашивается последний выпуск, больше ничего не отправляется.", "update.prefs_web": "Перезагрузите страницу, чтобы получить последнюю версию веб-приложения. Если сеть недоступна или обновление не успевает загрузиться, открывается сохранённая версия; незавершённая загрузка продолжится до следующей перезагрузки.", + "update.refresh_checking": "Проверка наличия новейшего выпуска…", + "update.refresh_fallback": "Не удалось проверить наличие более нового выпуска; обновление до {version}, найденного ранее.", + "update.refresh_selected": "Обновление до {version}.", "update.release_page": "Открыть страницу выпуска", "update.restart": "Перезапустить сейчас", "update.restart_later": "Позже", diff --git a/assets/locales/sr.json b/assets/locales/sr.json index 247fe30b2d..18c486b8bd 100644 --- a/assets/locales/sr.json +++ b/assets/locales/sr.json @@ -206,6 +206,7 @@ "Edges": "Ивице", "Edit": "Измени", "Edit Profile Sketch": "Измени скицу профила", + "Edit shortcuts": "Измени пречице", "Enable rule": "Омогући правило", "Enabled": "Омогућено", "End Radius": "Крајњи полупречник", @@ -468,6 +469,7 @@ "Radius": "Полупречник", "Radius Fit": "Уклапање полупречника", "Ratio": "Однос", + "Read-only list: shortcuts can't be changed here.": "Списак само за читање: пречице се овде не могу мењати.", "Reattach selected face": "Поново причврсти изабрано лице", "Recover": "Опорави", "Recover unsaved design?": "Опоравити несачувани дизајн?", @@ -652,6 +654,7 @@ "Third point": "Трећа тачка", "This child is shared. Deleting it removes it from every copy of its parent.": "Овај подређени елемент је дељен. Брисањем се уклања из сваке копије његовог родитеља.", "This child is shared. Position changes apply to every copy of its parent.": "Овај подређени елемент је дељен. Промене положаја примењују се на сваку копију његовог родитеља.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Ова компонента је унутар подсклопа који је постављен више пута: њено поравнање је помера у сваком постављању.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ово лице не може дати трајно причвршћење. Изаберите друго лице или изричито користите фиксну раван.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ова слика још није приложена на GitHub-у. Копирајте је или сачувајте, а затим је приложите на GitHub-у.", "Thread depth": "Дубина навоја", @@ -772,6 +775,8 @@ "at the face": "на лицу", "bom.group_children": "Подделови у реду свог дела", "browser.body_name": "Тело{number}", + "browser.calibrate_click_a": "Кликните слику да зададете тачку A или упишите њене координате.", + "browser.calibrate_click_b": "Кликните слику да зададете тачку B или упишите њене координате.", "browser.calibrate_title": "Калибриши: {name}", "browser.capture_many": "Померене компоненте без забележеног положаја: {count}.", "browser.capture_note": "Забележи задржава нови положај; Врати га враћа назад.", @@ -793,6 +798,11 @@ "cam.roller": "Ваљак", "cam.roller_pick": "Ваљак (необавезно)", "cam.tip_pick": "Врх (необавезно)", + "camera.shot_pitch": "Нагиб", + "camera.shot_roll": "Ваљање", + "camera.shot_yaw": "Скретање", + "canvas.decal_place_hint": "Кликните лице да поставите налепницу", + "canvas.place_hint": "Кликните раван или лице да поставите платно", "choose a profile": "изаберите профил", "choose the cam's and the follower's joints": "изаберите спојеве брега и пратиоца", "choose the joints to couple": "изаберите спојеве за спрезање", @@ -800,12 +810,16 @@ "click sketch curves": "кликните на криве скице", "cmd.appearance_assign": "Изглед", "cmd.appearance_chain": "Ланац изгледа", + "cmd.appearance_copy": "Копирај изглед", + "cmd.appearance_favorite": "Омиљени изглед", "cmd.appearance_library": "Библиотека изгледа", "cmd.appearance_list": "Списак изгледа", "cmd.appearance_paint": "Обоји изгледом", + "cmd.appearance_paste": "Налепи изглед", "cmd.appearance_pick_up": "Преузми изглед", "cmd.appearance_replace": "Замени изглед", "cmd.assembly_bom": "Саставница", + "cmd.body.print_face": "Површина за штампу", "cmd.body_rename": "Преименуј тело", "cmd.browser_collapse_all": "Скупи све", "cmd.browser_expand_all": "Прошири све", @@ -813,6 +827,7 @@ "cmd.browser_move": "Премести ставке стабла модела", "cmd.browser_order": "Поређај ставке стабла модела", "cmd.browser_rename_group": "Преименуј групу", + "cmd.browser_reparent": "Премести у компоненту", "cmd.browser_ungroup": "Разгрупиши", "cmd.browser_visibility": "Прикажи/сакриј", "cmd.canvas_calibrate": "Калибриши платно", @@ -839,7 +854,9 @@ "cmd.component_metadata": "Својства дела", "cmd.component_metadata_set": "Промени својства дела", "cmd.component_move_bodies": "Премести тела у компоненту", + "cmd.component_move_features": "Премести операције у компоненту", "cmd.component_move_sketches": "Премести скице у компоненту", + "cmd.component_paste_from_design": "Налепи из другог дизајна", "cmd.component_paste_new": "Налепи као ново", "cmd.component_properties": "Физичка својства компоненте", "cmd.component_relink": "Поново повежи извор", @@ -976,6 +993,7 @@ "cmd.motion_trace": "Траг кретања", "cmd.motion_trace_edit": "Измени траг кретања", "cmd.motion_trace_keep": "Задржи траг кретања", + "cmd.occurrence_align": "Поравнај примерак", "cmd.occurrence_copy": "Налепи (примерак)", "cmd.occurrence_drag": "Превуци примерак", "cmd.occurrence_ground": "Учврсти", @@ -1033,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Уклони везу површине", "cmd.render_library_targets": "Пронађи циљеве библиотеке рендеровања", "cmd.render_lights_add": "Додај светло", + "cmd.render_lights_duplicate": "Дуплицирај светло рендеровања", + "cmd.render_lights_list": "Списак светала рендеровања", + "cmd.render_lights_mute": "Искључи светло", + "cmd.render_lights_remove": "Уклони светло рендеровања", + "cmd.render_lights_set": "Подеси светло рендеровања", "cmd.render_lights_solo": "Само ово светло", "cmd.render_optics_bind": "Сачувај оптичку везу", "cmd.render_optics_unbind": "Уклони оптичку везу", "cmd.render_output_size": "Подеси величину излаза рендеровања", "cmd.render_preflight": "Провери захтев за рендеровање", + "cmd.render_preflight_copy": "Копирај извештај провере рендеровања", "cmd.render_preset_apply": "Примени изворни студио", "cmd.render_preset_resolve": "Прегледај изворни студио", "cmd.render_quality_prepare": "Прегледај фиксни буџет узорковања", @@ -1060,6 +1084,13 @@ "cmd.render_start": "Рендеруј слику", "cmd.render_status": "Напредак рендеровања", "cmd.select_clear": "Поништи избор", + "cmd.settings_profile_apply": "Примени профил подешавања", + "cmd.settings_profile_delete": "Избриши профил подешавања", + "cmd.settings_profile_export": "Извези профил подешавања", + "cmd.settings_profile_import": "Увези профил подешавања", + "cmd.settings_profile_list": "Профили подешавања", + "cmd.settings_profile_rename": "Преименуј профил подешавања", + "cmd.settings_profile_save": "Сачувај профил подешавања", "cmd.sheet_convert": "Претвори у лим", "cmd.sheet_export_bend_table": "Извези табелу савијања", "cmd.sheet_export_dxf": "Извези развијени облик као DXF", @@ -1073,6 +1104,16 @@ "cmd.sheet_refold": "Поново пресавиј", "cmd.sheet_rules": "Списак правила за лим", "cmd.sheet_unfold": "Развиј", + "cmd.sketch.construction_line": "Помоћна права", + "cmd.sketch.construction_line.angle": "Помоћна права под углом", + "cmd.sketch.construction_line.bisector": "Симетрала угла", + "cmd.sketch.construction_line.horizontal": "Хоризонтална помоћна права", + "cmd.sketch.construction_line.parallel": "Паралелна помоћна права", + "cmd.sketch.construction_line.perpendicular": "Нормална помоћна права", + "cmd.sketch.construction_line.tangent_at": "Тангента у тачки криве", + "cmd.sketch.construction_line.tangent_external": "Тангента кроз спољашњу тачку", + "cmd.sketch.construction_line.tangent_two": "Заједничка тангента", + "cmd.sketch.construction_line.vertical": "Вертикална помоћна права", "cmd.sketch_arc3d": "3D лук", "cmd.sketch_arc_center_point": "Лук из центра", "cmd.sketch_arc_tangent": "Тангентни лук", @@ -1105,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Величина цртежа", "cmd.sketch_edit": "Измени скицу", "cmd.sketch_edit_text": "Измени текст", + "cmd.sketch_equation_curve": "Крива по једначини", + "cmd.sketch_equation_curve_edit": "Измени криву по једначини", "cmd.sketch_export_dxf": "Сачувај као DXF", "cmd.sketch_finish_auto_constrain": "Заврши са аутоматским ограничењима", "cmd.sketch_fit_curves_to_section": "Уклопи криве у пресек мреже", @@ -1201,6 +1244,7 @@ "cmd.view.render_browser": "Стабло рендеровања", "cmd.view.render_dock": "Премести панел рендеровања", "cmd.view.render_focus": "Фокусни приказ рендеровања", + "cmd.view.status_items": "Ставке статуса у прозору приказа", "cmd.view_ambient_occlusion": "Амбијентална оклузија", "cmd.view_angle_readout": "Углови приказа", "cmd.view_apply": "Врати именовани приказ", @@ -1222,6 +1266,7 @@ "cmd.view_normal_to": "Нормално на избор", "cmd.view_previous_tab": "Претходна картица", "cmd.view_rename": "Преименуј именовани приказ", + "cmd.view_render_frame": "Водичи кадра рендеровања", "cmd.view_right": "Поглед здесна", "cmd.view_save": "Нови именовани приказ", "cmd.view_shots_strip": "Трака кадрова", @@ -1232,10 +1277,14 @@ "color.copy": "Кликните да копирате вредности боје", "color.edit": "Кликните да измените боју", "color.gamma": "Приказ вредности боје у гама простору 0-255", + "color.hex": "Hex", + "color.hex_help": "Унесите #rrggbb или #rrggbbaa; шест цифара чува непрозирност", + "color.hex_hint": "#rrggbb", "color.hue": "Нијанса", "color.linear": "Приказ вредности боје у линеарном простору 0-1", "color.saturation_value": "Засићеност и вредност", "color.selected": "Изабрана боја", + "config.column_filter_empty": "Нема одговарајућих колона", "contact.components_count": "Компоненте: {count}", "contact.enabled_label": "омогућено", "coupling.gear_1": "Зупчаник 1", @@ -1350,8 +1399,20 @@ "help.material.assign": "Одређује од чега су изабрана тела направљена, нпр. челик, алуминијум или ABS. Користите га да маса и тежина одговарају стварном делу.", "help.mit": "MIT лиценца", "help.notice": "NOTICE", + "help.occurrence.move": "Помера део склопа само дуж слобода које дозвољавају његови спојеви. Изаберите лице дела, а затим превуците доступну стрелицу или лук.", "help.parameters.change": "Отвара табелу именованих вредности и израза који управљају котама дизајна. Користите је да промените меру на једном месту, а све операције које је користе је прате.", "help.pointcloud.insert": "Уметните PLY, XYZ или PTS скенирање као облак тачака поред модела. Користите га као референцу при изради модела на основу измерених података.", + "help.sketch.construction_line": "Направите бесконачну помоћну праву кроз две тачке. Користите је да распоредите скицу без раздвајања профила.", + "help.sketch.construction_line.angle": "Направите бесконачну помоћну праву под углом од осе X скице. Изаберите тачку и упишите угао или изаберите смер.", + "help.sketch.construction_line.bisector": "Изаберите две праве линије скице које се секу, па једну од две симетрале угла. Резултат прати изворне линије.", + "help.sketch.construction_line.horizontal": "Направите бесконачну хоризонталну помоћну праву кроз тачку. Њено ограничење хоризонталности остаје на снази при каснијим изменама.", + "help.sketch.construction_line.parallel": "Изаберите праву линију скице, па поставите паралелну бесконачну помоћну праву. Десним кликом изаберите другу референцу.", + "help.sketch.construction_line.perpendicular": "Изаберите праву линију скице, па поставите нормалну бесконачну помоћну праву. Десним кликом изаберите другу референцу.", + "help.sketch.construction_line.tangent_at": "Направите тангенту у тачки на кругу или кружном луку, или у крајњој тачки отвореног сплајна или конике. Изаберите криву, па тачку додира.", + "help.sketch.construction_line.tangent_external": "Изаберите круг или кружни лук, па спољашњу тачку и решење тангенте. Прегледи приказују само тачке додира на нацртаном луку.", + "help.sketch.construction_line.tangent_two": "Изаберите два круга или кружна лука, па у прегледу изаберите заједничку тангенту. Тачке додира морају лежати на нацртаним кривама.", + "help.sketch.construction_line.vertical": "Направите бесконачну вертикалну помоћну праву кроз тачку. Њено ограничење вертикалности остаје на снази при каснијим изменама.", + "help.sketch.equation_curve": "Црта криву из формула: x(t) и y(t), или y = f(x), у задатом опсегу. Формуле могу да користе параметре и крива их прати: користите је за синусоиду, спиралу или евольвенту зупца зупчаника.", "help.solid.align": "Помера изабрана тела тако да се тачка, лице или раван на њима поклопи са тачком, лицем или равни негде другде. Користите га да поставите део уз други без мерења одмака.", "help.solid.chamfer": "Одсеца изабране ивице равним скошењем задатог растојања. Користите га да ублажите оштре ивице или олакшате увођење чивије или завртња.", "help.solid.combine": "Спаја тела, одузима их од циљног тела или задржава само заједничку запремину, а може да задржи и алатна тела. Користите га да спојите делове, да једним обликом одсечете други или да пронађете где се два тела преклапају.", @@ -1480,6 +1541,21 @@ "joint.one_component": "Ови делови су тела једне компоненте: {bodies}. Спој помера једну компоненту у односу на другу, па сваки део треба своју компоненту.", "joint.rigid_group_one_component": "Сва тела ({count}) су у једној компоненти (или у самом дизајну): прво користите {command}.", "joint.rigid_group_select_components": "Изаберите две или више компоненти.", + "joint_move.blocked_hint": "Проверите врсту споја (на пример Круто), учвршћивање, границе споја и вредности које покрећу параметри.", + "joint_move.blocked_title": "Овај део се не може померити", + "joint_move.coarse": "Корак ротације близу осе (°)", + "joint_move.distance": "Растојање од почетка", + "joint_move.done": "Готово", + "joint_move.fine": "Корак ротације далеко од осе (°)", + "joint_move.fixed": "Овај део нема покретну осу споја.", + "joint_move.help": "Помери — превуците стрелицу или прстен. Esc отказује тренутно превлачење.", + "joint_move.legacy_hint": "Искључите да бисте користили претходни панел Помери.", + "joint_move.precision": "Повуците изван испрекиданог водича за фину ротацију.", + "joint_move.rotation": "Ротација од почетка", + "joint_move.select": "Изаберите лице или један део склопа, а затим изаберите Помери.", + "joint_move.settings": "Померање у склопу", + "joint_move.step": "Корак", + "joint_move.use_handles": "Користи ручице кретања спојева за померање у склопу", "keymap.command_id": "ID команде", "keymap.empty": "Нема одговарајућих команди", "keymap.use_anyway": "Ипак користи {key} овде", @@ -1574,6 +1650,8 @@ "nav.maya.help": "Alt+превлачење левим орбитира, Alt+средњи помера, Alt+десни зумира; превлачење средњим такође помера", "nav.openscad.help": "Превлачење левим дугметом по празном простору орбитира, Shift+превлачење левим бира оквиром, превлачење геометрије скице је мења; превлачење десним или средњим дугметом помера, Shift+десно или Shift+средње зумира", "nav.perspective.sketch": "Измена скице користи ортографски приказ. Завршите скицу да пребаците на перспективу.", + "nav.right_drag_orbits.help": "Искључено: превлачење десним дугметом не дира камеру, а десно дугме само отвара контекстни мени. Избор SolidWorks мапирања миша га искључује.", + "nav.right_drag_orbits.label": "Превлачење десним дугметом орбитира", "nav.solidworks.help": "Превлачење средњим дугметом орбитира, Ctrl+средње помера, Shift+средње зумира, превлачење десним орбитира", "nav.tinkercad.help": "Превлачење десним дугметом орбитира, превлачење средњим или Shift+десним помера", "nav.touchpad.help": "Држите Shift и померајте показивач за померање приказа; држите Alt/Option и померајте га за орбитирање. Дугме миша није потребно. Вертикално скроловање зумира.", @@ -1595,6 +1673,9 @@ "navbar.custom.customise": "Прилагоди навигациону траку…", "navbar.custom.design": "Дизајн", "navbar.custom.duplicate": "Ово место је већ на траци.", + "navbar.custom.edge": "Ивица", + "navbar.custom.edge_bottom": "Доле", + "navbar.custom.edge_top": "Горе", "navbar.custom.fade": "Избледи", "navbar.custom.full": "Уклоните једно место да бисте додали друго.", "navbar.custom.hidden_design": "Скривено у Дизајну", @@ -1701,6 +1782,7 @@ "parts.dimension.thickness": "Дебљина", "parts.dimension.width": "Ширина", "parts.family.bearing": "Куглични лежај са дубоким жлебом (габарит)", + "parts.family.button_head_cap_screw": "Завртањ са полуоблом главом и унутрашњим шестоугаоником", "parts.family.dowel_pin": "Паралелни клин (колчић)", "parts.family.hex_bolt": "Завртањ са шестоугаоном главом", "parts.family.hex_nut": "Шестоугаона навртка", @@ -1759,6 +1841,11 @@ "prefs.bottom_centre": "Доле у средини", "prefs.bottom_right": "Доле десно", "prefs.browser": "Стабло модела", + "prefs.browser_eye_left": "Око поред стрелице за скупљање", + "prefs.browser_eye_left_note": "Редови стабла модела приказују око за приказивање/скривање поред стрелице за скупљање, као раније, уместо у колони на десном крају редова.", + "prefs.browser_reveal_on_hover": "Прикажи при преласку мишем", + "prefs.browser_show_note": "Приказано држи стабло модела на екрану. Прикажи при преласку мишем га скрива док показивач не дође на његово место или на танак језичак на његовој левој ивици, а остаје отворено док је показивач у њему или док је отворен мени из њега.", + "prefs.browser_shown": "Приказано", "prefs.built_in": "Уграђено", "prefs.button_left": "Превлачење левим дугметом", "prefs.button_middle": "Превлачење средњим дугметом", @@ -1806,6 +1893,8 @@ "prefs.graphics_card_restart": "Ступа на снагу након поновног покретања SolidCraft-а. WGPU_ADAPTER_NAME има предност над овим избором.", "prefs.graphics_note": "Жртвујте брзину за глаткоћу. Сваки избор показује свој учинак на примеру.", "prefs.graphics_presets_note": "Унапред подешене вредности подешавају све изборе испод одједном.", + "prefs.hide_constraint_glyphs_zoomed_out": "Сакриј симболе ограничења када је приказ умањен", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Симболи ограничења нестају из приказа када је скица толико умањена да би је затрпали. Искључено: остају фиксне величине на екрану при сваком зумирању.", "prefs.history": "Временска трака", "prefs.history_origin_bodies": "Прикажи исходиште и тела на врху", "prefs.history_origin_bodies_note": "Временска трака лево или испод стабла модела почиње фасциклом Тела активне компоненте и Исходиштем, са њиховим оком и менијима као у стаблу модела. Временска трака на дну остаје непромењена.", @@ -1829,6 +1918,8 @@ "prefs.layout_note": "Компактно причвршћује панеле уз ивице прозора. Провидност стабла модела доступна је у опцији Плутајуће. Страну Асистента промените на страници Асистент.", "prefs.left": "Лево", "prefs.left_click": "Леви клик", + "prefs.line_drag_draws_one": "Притисак и превлачење цртају једну линију", + "prefs.line_drag_draws_one_note": "У алату Линија, притисак, превлачење и пуштање цртају једну линију од места притиска до места пуштања и ту се завршава. Кликови и даље цртају ланац. Искључено: превлачење поставља тачку где се пусти, као и клик.", "prefs.linked_designs": "Повезани дизајни", "prefs.live_input": "Улаз уживо", "prefs.live_preview": "Преглед уживо", @@ -1842,6 +1933,9 @@ "prefs.max_file_size_step": "STEP и датотеке за увоз", "prefs.max_file_size_web": "Веб апликација отвара STEP и датотеке за увоз до 512 MB и дизајне до 256 MB: картица прегледача не може да садржи веће датотеке. Десктоп апликација може да повећа ова ограничења.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Велике иконе", + "prefs.menu_list": "Списак", + "prefs.menus": "Менији", "prefs.minutes": "минута", "prefs.minutes_suffix": " min", "prefs.mouse": "Миш и додирна табла", @@ -1861,14 +1955,40 @@ "prefs.origin_plane_names": "Називи равни исходишта", "prefs.origin_plane_names_note": "Називи приказани у стаблу модела, дијалозима и листама. {top} је XY, {front} је XZ, а {right} је YZ, са Z нагоре. Команде, скрипте и сачувани дизајни задржавају XY, XZ и YZ.", "prefs.overall_speed": "Укупна брзина", + "prefs.persistent_ribbon_pressed": "Истакни активне команде на траци", + "prefs.persistent_ribbon_pressed_help": "Истиче команде на траци док су њихови ефекти укључени. Искључите да бисте задржали претходни неутрални изглед и помоћ за команде.", + "prefs.perspective_fov": "Видно поље", "prefs.placement": "Постављање", "prefs.prefer_free_sketch_points": "Дај предност покретним тачкама скице када се тачке преклапају", "prefs.preview_choices": "Преглед ваших избора", + "prefs.print_face_highlight": "Истицање површине за штампу", + "prefs.print_face_highlight_tip": "Колико се јако површина за штампу тела боји и обрубљује у приказу. 0 % не приказује истицање.", + "prefs.profile_active": "Активни профил", + "prefs.profile_applied": "Профил је примењен.", + "prefs.profile_changed": "Подешавања су промењена откако је профил сачуван. Притисните Сачувај да их задржите у њему.", + "prefs.profile_deleted": "Профил је избрисан.", + "prefs.profile_export": "Извези…", + "prefs.profile_exported": "Профил је извезен.", + "prefs.profile_imported": "Профил је увезен. Изаберите га под Активни профил да бисте прешли на њега.", + "prefs.profile_name_hint": "Назив профила, нпр. Лаптоп", + "prefs.profile_rename": "Преименуј", + "prefs.profile_renamed": "Профил је преименован.", + "prefs.profile_save": "Сачувај", + "prefs.profile_saved": "Тренутна подешавања су сачувана у профил.", + "prefs.profile_skipped": "Профил је примењен; подешавања која су задржала тренутну вредност: {n} (погледајте дневник).", + "prefs.profiles": "Профили подешавања", + "prefs.profiles_note": "Пречице, трака са алатима, навигација, радијални мени и подешавања интерфејса, чувани заједно под једним називом и мењани одједном.", + "prefs.project_options_dialog": "Дијалог опција за Пројектуј", + "prefs.project_options_dialog_note": "Укључено: пре пројектовања изаберите Геометрија или Тела и Веза пројекције. Искључено: задржава се претходни начин рада са директним избором.", "prefs.quality_default": "подразумевано", "prefs.quality_fastest": "најбрже", "prefs.quality_slowest": "најспорије", "prefs.quality_smoother": "глађе", + "prefs.render_previous_geometry_budget": "Користи претходни буџет геометрије за преглед у Рендеровању", + "prefs.render_previous_geometry_budget_help": "Враћа претходно фиксно ограничење геометрије (512 MiB на 64-битним десктоп рачунарима, 128 MiB другде) уместо буџета према уређају.", "prefs.reports_saved": "Сачувано: {n}", + "prefs.ribbon_reveal": "Прикажи резултате претраге на траци", + "prefs.ribbon_reveal_note": "Задржавање показивача изнад резултата у Претражи команде показује где се команда налази на траци: њену картицу, њено дугме или њен ред у падајућем менију панела. Команда покренута из претраге тамо трепери, а покренута команда из падајућег менија означава свој панел.", "prefs.right_click": "Десни клик", "prefs.saving": "Аутоматско чување и опоравак", "prefs.scale_note": "Такође Ctrl + / Ctrl − у прозору. Дугмад су 28 px при 100%.", @@ -1877,18 +1997,29 @@ "prefs.search_empty": "Нема одговарајућих подешавања", "prefs.select": "Избор", "prefs.select_one_item": "Изабери једну ставку", + "prefs.select_other_hidden": "Изабери скривену геометрију помоћу Изабери друго", + "prefs.select_other_hidden_help": "Укључено: обухвата скривена тела, скице и заклоњене ивице у близини показивача. Искључено: користи се изворни списак видљиве геометрије испод показивача.", "prefs.selecting": "Бирање", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift само додаје; Ctrl / Cmd укључује и искључује", "prefs.shift_add_note": "Укључено: Shift додаје, Ctrl / Cmd укључује и искључује. Искључено (подразумевано): оба укључују и искључују.", "prefs.shortcuts": "Пречице", + "prefs.single_surface_analysis": "Користи само једну анализу површи", + "prefs.single_surface_analysis_help": "Када је искључено, сенчење површи и Континуитет могу остати укључени заједно. Када је укључено, остаје последња покренута или промењена анализа.", + "prefs.sketch_align_points": "Поравнај са другим тачкама скице током цртања", + "prefs.sketch_align_points_note": "Током цртања тачка се истовремено поравнава хоризонтално са једном тачком скице и вертикално са другом, уз тачкасти водич, а клик задржава оба поравнања. Искључено: само са претходном тачком, по једна оса.", "prefs.sketch_conflict_colour": "Геометрија у сукобу у боји сукоба", "prefs.sketch_conflict_colour_note": "Док су ограничења скице у сукобу, криве и тачке које држи ограничење које не успева исцртавају се такође у боји сукоба, а не само његов симбол или кота. Искључено: задржавају уобичајене боје.", + "prefs.sketch_construction_lines": "Проширени алати за помоћне праве", "prefs.sketch_exit_view": "Када се скица заврши", "prefs.sketch_exit_view.keep": "Задржи тренутни приказ", "prefs.sketch_exit_view.previous": "Врати се на претходни приказ", "prefs.sketch_exit_view.unless_rotated": "Врати се осим ако је ротирано", "prefs.sketch_exit_view_note": "Куда приказ иде након Заврши скицу, Откажи или двоструког Esc.", + "prefs.sketch_open_look_at": "Окрени камеру према скици када се отвори", + "prefs.sketch_open_look_at_note": "Искључено: када направите или измените скицу, приказ остаје онакав какав јесте. Погледај окреће приказ према скици када то желите.", + "prefs.sketch_recent_snap": "Аутоматски привлачи на недавне центре и средишње тачке", + "prefs.sketch_recent_snap_note": "Током цртања показивач се привлачи тамо где се секу водичи двају последњих центара кругова или средишњих тачака правих ивица. Искључено: ти водичи служе само за приказ и показивач се не помера.", "prefs.sketch_selection": "Скица и избор", "prefs.sketch_selection_heading": "Скица и избор", "prefs.sketch_selection_note": "Како се скице привлаче и како кликови бирају.", @@ -1933,7 +2064,14 @@ "prefs.toast_place": "Обавештења", "prefs.toggle_item": "Додај ставку или је поново уклони", "prefs.toolbar": "Трака са алатима", + "prefs.toolbar_tabs": "Картице траке са алатима", "prefs.top": "Горе", + "prefs.trackpad_zoom_modifier": "Зумирај скроловањем са два прста док држите", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Држите само овај тастер да зумирате према показивачу вертикалним скроловањем. Искључено задржава постојеће понашање скроловања.", + "prefs.trackpad_zoom_modifier.off": "Искључено", "prefs.translucent": "Провидно", "prefs.under_browser": "Испод стабла модела", "prefs.under_cursor": "Испод показивача", @@ -1943,13 +2081,24 @@ "prefs.view_confirm_corner": "У реду и Откажи у углу приказа", "prefs.view_confirm_corner_note": "Док је отворен дијалог или скица, квачица и крстић горе десно у приказу раде исто што и дугмад У реду и Откажи у дијалогу, односно завршавају и отказују скицу. Навигатор приказа помера се испод њих.", "prefs.viewport": "Прозор приказа", + "prefs.viewport_analysis_status": "Прикажи статус анализе у прозору приказа", + "prefs.viewport_analysis_status_help": "Приказује активне анализе изнад прозора приказа, отвара њихове легенде из сваке ставке, а тастер Esc искључује најновију анализу. Искључите да бисте вратили легенде у угловима и претходно понашање тастера Esc.", + "prefs.viewport_background": "Позадина", + "prefs.viewport_background_colour": "Боја", + "prefs.viewport_background_theme": "Градијент теме", "prefs.wheel": "Точкић", "prefs.wheel_trackpad": "Точкић и додирна табла", "prefs.workspace": "Радни простор", + "prefs.zoom_stop_at_model": "Заустави зумирање на моделу", + "prefs.zoom_stop_at_model_tip": "3D миш и апликација уређаја зумирају према површини у средини приказа; зумирање превлачењем држи тачку где је превлачење почело. Искључено: зумирање може да прође кроз модел. Точкић и штипање засебно користе Зумирање према показивачу", "prefs.zoom_toward_cursor": "Зумирање према показивачу", "properties.title": "Својства: {name}", "recovery.days_ago": "пре {count} дана", "recovery.detail": "{file} · операција: {features} · аутоматски сачувано {when}", + "recovery.discard_all": "Одбаци све", + "recovery.discard_all_detail": "Ово се не може поништити.", + "recovery.discard_all_title": "Одбацити аутоматска чувања ({count})?", + "recovery.discard_all_title_one": "Одбацити 1 аутоматско чување?", "recovery.hours_ago": "пре {count} h", "recovery.just_now": "управо сада", "recovery.min_ago": "пре {count} min", @@ -2034,6 +2183,7 @@ "render.appearance_editor.weight": "Тежина", "render.appearance_physics_separate": "Визуелни изглед не додељује густину, топлотне податке нити друга инжењерска својства.", "render.appearances": "Изгледи", + "render.assign_help": "Кликните да примените на избор; превуците за преглед и пустите, или кликните без избора да бојите.", "render.availability_native": "Изворни састав; прегледајте пре примене", "render.availability_projection": "Подржана пројекција; погледајте опсег и изоставке", "render.availability_reference": "Референтни подаци; није аутоматска додела", @@ -2072,6 +2222,7 @@ "render.bloom_threshold": "Праг линеарних светлих делова", "render.browse_capabilities": "Прегледај комплетан каталог могућности", "render.browse_environments": "Прегледај изворна HDR окружења", + "render.browse_looks": "Прегледај…", "render.browse_native_scenes": "Прегледај изворне студије", "render.browse_physical": "Прегледај физичке референце", "render.browse_quality": "Прегледај спецификације квалитета", @@ -2095,6 +2246,82 @@ "render.check_opaque": "Провидност: само непрозирне површине; алфа позадине је подржана.", "render.check_sorted_alpha": "Провидност: апроксимација сортираном алфом.", "render.check_transmission": "Провидност: физичко пропуштање.", + "render.children.absolute_floor": "Апсолутни доњи праг", + "render.children.adaptive": "Адаптивно", + "render.children.add_look_reason": "Додајте изглед да бисте мењали да ли је омогућен и његову јачину.", + "render.children.all_shots": "Сви кадрови", + "render.children.bloom": "Сјај", + "render.children.check": "Провера", + "render.children.check_now": "Провери сада", + "render.children.chromatic": "Хроматски помак", + "render.children.compact": "Компактно", + "render.children.contrast": "Контраст", + "render.children.copy_image": "Копирај", + "render.children.copy_job": "Копирај JSON посла", + "render.children.copy_request": "Копирај JSON захтева", + "render.children.coverage_error": "Грешка покривености", + "render.children.cpu": "CPU праћење зрака", + "render.children.denoise": "Смањи шум на завршеној слици", + "render.children.depth": "Дубина путање", + "render.children.distortion": "Изобличење", + "render.children.edge_samples": "Узорци ивица", + "render.children.effects_banner": "Ови ефекти захтевају GPU праћење путања. Отварање овога никад не мења методу.", + "render.children.enable_adaptive": "Омогућите независне пробне узорке да бисте мењали адаптивне границе.", + "render.children.enable_bloom": "Омогућите сјај да бисте мењали његову јачину, праг и полупречник.", + "render.children.finishing": "Завршна обрада", + "render.children.flare": "Одсјај", + "render.children.fresnel": "Fresnel-ови слојеви", + "render.children.ghosts": "Духови прстенова", + "render.children.glass": "Пренос кроз стакло", + "render.children.go_to": "Иди на {editor}", + "render.children.grain": "Зрно", + "render.children.halo": "Ореол", + "render.children.hdr": "HDR", + "render.children.keep_master": "Задржи сирови линеарни мастер", + "render.children.last_result": "Последњи: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Ефекти објектива", + "render.children.look_summary": "{tone} · изгледи: {count}", + "render.children.looks": "Креативни изгледи", + "render.children.memory": "Меморија", + "render.children.method": "Метода", + "render.children.minimum": "Минимум", + "render.children.needs_cpu": "Потребно је CPU праћење зрака. Отварање овога никад не мења методу.", + "render.children.nested": "Потврђени угнежђени диелектрици", + "render.children.not_checked": "Није проверено", + "render.children.path": "Праћење путања", + "render.children.pilots": "Пробни узорци", + "render.children.preview": "Подаци прегледа", + "render.children.progressive_reason": "Изаберите Прогресивно да бисте мењали број узорака и семе.", + "render.children.radius": "Полупречник", + "render.children.reflect_depth": "Дубина рефлексије", + "render.children.reflect_samples": "Узорци рефлексије", + "render.children.regrade": "Поновна градација", + "render.children.relative_error": "Релативна грешка", + "render.children.render": "Рендеровање", + "render.children.render_shot": "Рендеруј кадар", + "render.children.renderer": "Рендерер", + "render.children.renderers": "Рендерери", + "render.children.request": "Овај захтев", + "render.children.reset_finishing": "Ресетуј завршну обраду", + "render.children.review_quality": "Прегледај спецификацију квалитета…", + "render.children.rgb_gain": "RGB појачање", + "render.children.samples": "Узорци", + "render.children.sampling": "Узорковање", + "render.children.saturation": "Засићеност", + "render.children.scene_summary": "тела: {bodies} · троуглови: {triangles}", + "render.children.seed": "Семе", + "render.children.sharpening": "Изоштравање", + "render.children.single_image": "Једна слика", + "render.children.soft_focus": "Меки фокус", + "render.children.streak": "Пруга", + "render.children.strength": "Јачина", + "render.children.studio": "Студио", + "render.children.threshold": "Праг", + "render.children.tone": "Тон", + "render.children.under_hood": "Испод хаубе", + "render.children.vignette": "Вињета", + "render.children.workstation": "Радна станица", + "render.choose_look": "Прво изаберите изглед у Изгледима", "render.chromatic_offset": "Хроматски помак ивица", "render.clear_stage": "Уклони сачувану сцену", "render.copy_request": "Копирај JSON", @@ -2346,8 +2573,33 @@ "render.flare_strength": "Јачина одсјаја светлих делова", "render.flare_threshold": "Линеарни праг одсјаја сцене", "render.fold_browser": "Скупи стабло модела", + "render.frame_aspect": "Однос страница", + "render.frame_centre_mark": "Ознака центра", + "render.frame_copy_limit": "Нема слободног назива за дупликат кадра", + "render.frame_copy_name": "{name} копија {number}", + "render.frame_custom": "Прилагођено", + "render.frame_distance_unit": " × величина", + "render.frame_duplicate": "Дуплицирај", + "render.frame_exposure": "Експозиција", + "render.frame_fov": "Видно поље", + "render.frame_guides": "Водичи", + "render.frame_invalid_aspect": "Неисправан унапред подешен однос страница", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Назив", + "render.frame_output_size": "Величина излаза", + "render.frame_photo": "3:2 · Фотографија", + "render.frame_portrait": "4:5 · Портрет", "render.frame_presets": "Унапред подешени кадрови", "render.frame_presets_pending": "Стиже са уређивачем кадрова", + "render.frame_projection": "Пројекција", + "render.frame_render_shot": "Рендеруј овај кадар", + "render.frame_rule_thirds": "Правило трећина", + "render.frame_safe_area": "Безбедна зона", + "render.frame_shot_title": "Кадар · {name}", + "render.frame_square": "1:1 · Квадрат", + "render.frame_tall": "9:16 · Усправно", + "render.frame_thirds": "Трећине", + "render.frame_wide": "16:9 · Широки екран", "render.gpu_depth": "Максимална дубина путање", "render.gpu_options_help": "Узорци, дубина, семе и експозиција чувају се са овим подешавањем. Чува се само активна метода слике.", "render.gpu_path_failed": "GPU праћење путања није доступно", @@ -2375,6 +2627,7 @@ "render.heading_results": "РЕЗУЛТАТИ", "render.heading_shots": "КАДРОВИ", "render.hide_shots_label": "Сакриј траку кадрова", + "render.hover_look": "{object} · {look} · кликните за избор", "render.image_advanced": "Напредне GPU контроле", "render.image_cancelled": "Рендеровање је отказано", "render.image_cancelling": "Отказивање", @@ -2521,17 +2774,95 @@ "render.library_uniform_ack": "Користи једноличну апроксимацију без просторних или напредних ефеката", "render.library_usable": "Извршне пројекције", "render.library_users": "Корисници", + "render.light_aim": "Усмери ка", "render.light_anchor_pending": "Промена координатног система светла још није подржана.", + "render.light_azimuth": "Азимут", + "render.light_backend_limit": "Изаберите CPU праћење зрака или изричити GPU студио да бисте мењали светла. Светла старог GPU студија остају само за читање.", + "render.light_behaviour": "ПОНАШАЊЕ", + "render.light_default_fill": "Допунско", + "render.light_default_key": "Главно", + "render.light_default_number": "Светло {number}", + "render.light_default_rim": "Рубно", + "render.light_delete": "Избриши", + "render.light_distance": "Растојање", + "render.light_duplicate": "Дуплицирај", + "render.light_elevation": "Елевација", + "render.light_gpu_punctual": "Тачкаста светла и рефлектори доступни су уз CPU праћење зрака.", + "render.light_hard_shadow": "Ово светло има оштре сенке; мекоћа емитера није доступна.", + "render.light_intensity": "Интензитет", + "render.light_missing": "Ово светло више не постоји. Изаберите светло у стаблу модела.", + "render.light_place_highlight": "Постави према одсјају", + "render.light_place_pending": "Постављање према одсјају захтева избор светла у прозору приказа и још није подржано.", + "render.light_position": "ПОЛОЖАЈ", + "render.light_preview_help": "Приказ уживо је без сенки; површинска светла користе апроксимацију узорком из центра. Рендеровање користи потпуни модел светла.", + "render.light_product_centre": "Центар производа", + "render.light_size": "Величина", + "render.light_softness": "Мекоћа", + "render.light_temperature": "Температура боје", + "render.light_temperature_help": "Приближна нијанса црног тела; чува се као линеарни RGB.", + "render.light_type": "Врста", + "render.light_type_area": "Површинско", + "render.light_type_point": "Тачкасто", + "render.light_type_spot": "Рефлектор", + "render.light_type_sun": "Сунце", + "render.light_view_only": "Само једно светло и искључена светла утичу на овај приказ и његове рендере; никад се не чувају у дизајну.", + "render.light_world_origin": "Исходиште света", + "render.lighting_aim_point": "Тачка циљања", + "render.lighting_apply": "Примени подешавање", + "render.lighting_area_samples": "Узорци површинског светла", + "render.lighting_budget": "Буџет", + "render.lighting_budget_help": "Бројеви су само за читање. Додајте, дуплицирајте или избришите светла да бисте их променили; метода одређује ограничења.", + "render.lighting_colour": "Боја", + "render.lighting_consent": "Замени усмерена светла овим подешавањем", + "render.lighting_custom": "Прилагођена светла", + "render.lighting_custom_help": "Избор опције Прилагођено претвара подешавање у светла која се могу мењати, свако у свом реду у стаблу.", + "render.lighting_direction": "Смер X Y Z", + "render.lighting_direction_help": "Или упишите смер према светлу (X Y Z).", + "render.lighting_distance_unit": " × величина", + "render.lighting_edge_u": "Вектор странице U", + "render.lighting_edge_v": "Вектор странице V", + "render.lighting_falloff": "Слабљење", + "render.lighting_falloff_help": "Интензитет је наведен на референтном растојању.", + "render.lighting_height": "Висина V", + "render.lighting_minimum": "Минимум", + "render.lighting_mute": "Искључи ово светло", + "render.lighting_point": "Тачка", + "render.lighting_preset": "Подешавање студија", + "render.lighting_preset_cpu": "Подешавања студија са усмереним светлима захтевају CPU праћење зрака. GPU осветљење користи изричита сунчева и површинска светла.", + "render.lighting_preset_row": "Подешавање", + "render.lighting_radiance": "Радијанса", + "render.lighting_reference": "Референца", + "render.lighting_review_help": "Замењује списак усмерених светала. Задржава тачкаста и површинска светла, окружење, сенке, камеру и сва остала подешавања кадра.", + "render.lighting_review_title": "Прегледај подешавање осветљења", + "render.lighting_samples_cpu": "Узорци површинског светла важе за CPU праћење зрака.", + "render.lighting_samples_need_area": "Додајте CPU површинско светло да бисте подесили узорке површинског светла.", + "render.lighting_samples_reason": "Узорци површинског светла морају бити 1, 4, 16 или 64.", + "render.lighting_shadow_cpu": "Прекидач сенки захтева CPU праћење зрака. GPU праћење путања увек баца сенке.", + "render.lighting_shadows": "Баци сенке", + "render.lighting_shadows_section": "Сенке и узорковање", + "render.lighting_shape": "Облик", + "render.lighting_solo": "Само ово светло", + "render.lighting_source": "Извор", + "render.lighting_source_section": "Извор светла", + "render.lighting_spot_preserved": "Ово тачкасто светло задржава сачувани конус рефлектора док му се не промени врста.", + "render.lighting_sun_distance": "Смер сунца не зависи од растојања и тачке циљања.", + "render.lighting_unsupported": "неподржано", + "render.lighting_used": "{used} од {limit}", + "render.lighting_width": "Ширина U", + "render.lighting_world_position": "Светски X Y Z", "render.lights_fixed_to": "Светла везана за", "render.linear_help": "Задржава 32-битни флоат RGBA мастер пре уклањања шума, експозиције, мапирања тонова, филтера и позадине. Линеарни sRGB/D65; помножен покривеношћу. Додатна меморија се изричито одобрава. Без компресије и додатних пролаза.", "render.linear_retain": "Задржи сирови линеарни EXR", "render.linear_unavailable": "Изабрани завршени кадар нема задржани EXR. Омогућите задржавање пре рендеровања; старији PNG не може да врати сирову радијансу.", "render.look_add": "Додај изглед боје", + "render.look_applied": "Примењено: {name}", "render.look_bleach": "По узору на bleach bypass", "render.look_cool": "Хладно", "render.look_cyanotype": "По узору на цијанотипију", "render.look_faded": "Избледели отисак", + "render.look_material": "Материјал", "render.look_mono": "Једнобојно", + "render.look_pasted": "Изглед је налепљен", "render.look_sepia": "Сепија", "render.look_strength": "Јачина изгледа", "render.look_teal_orange": "Тиркизно / наранџасто", @@ -2607,9 +2938,11 @@ "render.output_advanced": "Напредно", "render.output_backend_ready": "Спремно", "render.output_check": "Провери", + "render.output_check_failed": "Провера · грешка", "render.output_check_help": "Покрените проверу да бисте прегледали овај захтев без покретања рендеровања.", "render.output_check_summary": "Провера · Напомене: {count}", "render.output_copy": "Копирај", + "render.output_copy_report": "Копирај извештај", "render.output_destination": "Одредиште", "render.output_destination_help": "Одредишну датотеку изаберите при извозу завршене слике.", "render.output_dimensions": "Величина", @@ -2619,6 +2952,7 @@ "render.output_fit": "Уклопи", "render.output_format": "Формат", "render.output_format_status": "PNG је тачна завршена слика за приказ. Необавезни GPU EXR задржава сирову линеарну радијансу сцене у формату FLOAT32 пре филтера приказа. Извоз никад не замењује кеш прегледа ни други посао.", + "render.output_grading": "Градација", "render.output_image": "Слика", "render.output_largest": "Највише {width} × {height}", "render.output_lock_ratio": "Закључај однос страница", @@ -2628,6 +2962,8 @@ "render.output_quality_waiting": "чека се уговор о квалитету", "render.output_ratio_refused": "Повезана димензија премашује ограничење величине ове методе. Претходна величина се задржава; откључајте однос или изаберите мању вредност.", "render.output_ready": "Спремно за рендеровање", + "render.output_run_check": "Покрени проверу", + "render.output_size_limit": "{method}: до {dimension} px по страни и {pixels} пиксела. Највећа величина која стаје: {width} × {height}.", "render.output_size_ok": "Величина је у реду · {width} × {height} px", "render.output_swap_orientation": "Замени ширину и висину", "render.output_tools_inspector": "Величину и формат измените у инспектору Излаз.", @@ -2644,6 +2980,7 @@ "render.page_output": "Излаз", "render.page_scene": "Сцена", "render.paint_label": "Обоји", + "render.painting_with": "Бојење изгледом {name}", "render.passive_stage": "Подлога и пасивна сцена", "render.passive_stage_help": "Коначни пасивни панели припадају само рендерованим сликама. Не додају CAD тела ни масу. Постављање је фиксно након примене; пресецање граница производа се одбија. CPU не даје дифузно одбијено осветљење.", "render.perspective": "Перспективна камера", @@ -2665,6 +3002,7 @@ "render.pick_help": "Изаберите Тело, Лице или Компонента. Кликните празан простор да очистите избор; Shift или Ctrl додаје или укључује/искључује. Истицања се појављују само у живом приказу производа.", "render.pick_label": "Изабери", "render.pick_up_label": "Преузми", + "render.pick_up_prompt": "Кликните објекат да преузмете његов изглед", "render.pick_waiting": "Избор производа још није подржан.", "render.post_contrast": "Контраст", "render.post_gain_b": "Појачање плаве", @@ -2704,13 +3042,17 @@ "render.radial_distortion": "Радијално изобличење", "render.radial_fit": "Уклопи производ", "render.radial_save": "Сачувај кадар", + "render.recent_looks": "Недавни изгледи", "render.regrade_action": "Поновна градација сировог мастера (без уклањања шума)", "render.regrade_help": "Прави засебан PNG из изабраног завршеног сировог мастера, користећи ове контроле приказа. Не прати зраке, не мења забележену величину, камеру ни светла и не преписује EXR. Уклањање шума се не примењује поново: сирови мастери не чувају његове водиље. Изведена слика се и даље може извести истим контролама посла.", "render.regrade_label": "Поновна градација", "render.reload_settings": "Поново учитај сачувана подешавања", + "render.remove_override": "Уклони надјачавање", + "render.replace_all": "Замени све", "render.replace_ground": "Потврди замену постојеће сцене", "render.replace_label": "Замени", "render.replace_saved_surface": "Замени постојећу сачувану везу за овај циљ", + "render.replace_with": "Замени са", "render.restore_camera": "Врати сачувану камеру", "render.result_actual": "100%", "render.result_actual_help": "Један пиксел слике по пикселу екрана.", @@ -2762,14 +3104,28 @@ "render.saved_surface_remove": "Уклони сачувану везу", "render.saved_surfaces": "Сачуване везе површина", "render.saved_surfaces_help": "Фиксиране уграђене мапе прате изабрани примерак у локалним милиметрима. Замењен или недостајући циљ је грешка, никад аутоматска поновна додела.", + "render.scene_all_shots": "Сви кадрови ({count})", "render.scene_apply": "Примени прегледани студио", + "render.scene_batch_reason": "За Све кадрове потребан је ред чекања за рендеровање сачуваних кадрова, који још није доступан.", + "render.scene_check_now": "Провери сада", + "render.scene_check_passed": "Провера је прошла · напомене: {notes}", + "render.scene_check_stale": "Провера је застарела · проверите поново", + "render.scene_check_unchecked": "Још није проверено · напомене: 0", "render.scene_consent": "Потврди замену осветљења, окружења, експозиције и сцене", + "render.scene_light_summary": "{environment} · светла: {lights}", + "render.scene_look_summary": "изгледи: {looks} · надјачавања: {overrides}", + "render.scene_open_check": "Отвори проверу", "render.scene_preserved_fields": "Камера, узорковање, димензије, оптичке везе, CAD геометрија и инжењерски материјали се чувају.", "render.scene_preserves_design": "Саставите осветљење и пасивну сцену за видљиви предмет. Геометрија, инжењерски материјали и камера остају одвојени.", + "render.scene_ready": "Спремно за рендеровање", + "render.scene_reminder": "Сцена чува дизајн. Геометрија, инжењерска својства и CAD никад се не мењају из Рендеровања.", + "render.scene_reminders": "Подсетници", + "render.scene_renderer_ready": "Спремно: {renderer}", "render.scene_replaces": "Замењује", "render.scene_review": "Разреши и прегледај", "render.scene_review_help": "Прегледајте комплетну изворну композицију пре примене. Невезане напредне SCENE спецификације се не замењују.", "render.scene_scope_details": "Опсег композиције", + "render.scene_stages": "Фазе", "render.search_appearances": "Претражи {count} изгледа", "render.setup_legacy_environment": "Ово старије подешавање користи наслеђену ротацију или интензитет окружења. Те вредности се задржавају; изричито их претворите пре чувања пуног посла слике.", "render.setup_name": "Назив подешавања", @@ -2782,7 +3138,22 @@ "render.shell_tab_camera": "КАМЕРА РЕНДЕРА", "render.shell_tab_lighting": "ОСВЕТЉЕЊЕ", "render.shell_tab_scene": "СЦЕНА РЕНДЕРА", + "render.shot_aperture": "Отвор бленде", + "render.shot_distance": "Растојање", + "render.shot_dof": "Дубинска оштрина", "render.shot_duplicate": "Дуплицирај сачувани кадар", + "render.shot_edit_output": "Измени у Излазу", + "render.shot_exposure_reason": "Експозиција захтева CPU праћење зрака или GPU праћење путања.", + "render.shot_exposure_section": "Експозиција", + "render.shot_focus_distance": "Растојање фокуса", + "render.shot_fov_reason": "Видно поље захтева перспективну пројекцију.", + "render.shot_frame_subject": "Уоквири предмет", + "render.shot_invalid_camera": "Вредности камере морају бити коначне и у дозвољеном опсегу.", + "render.shot_lens": "Објектив", + "render.shot_lens_enable_reason": "Омогућите дубинску оштрину да бисте мењали отвор бленде и растојање фокуса.", + "render.shot_lens_gpu_reason": "Дубинска оштрина захтева GPU праћење путања.", + "render.shot_lens_perspective_reason": "Дубинска оштрина захтева перспективну пројекцију.", + "render.shot_look_selection": "Усмери на избор", "render.shot_manager": "Сачувани кадрови", "render.shot_new_name": "Назив новог кадра", "render.shot_remove": "Уклони сачувани кадар", @@ -2790,6 +3161,9 @@ "render.shot_remove_title": "Уклонити сачувани кадар?", "render.shot_rename": "Преименуј сачувани кадар", "render.shot_saved_only": "Прво сачувајте тренутни нацрт. Дуплицирање и преименовање чувају тачне сачуване опције и референце на ресурсе.", + "render.shot_selection_reason": "Изаберите тело или лице на које ћете усмерити камеру.", + "render.shot_size": "Величина", + "render.shot_target": "Циљ", "render.shots_camera_changed": "Камера је промењена откако је овај кадар сачуван", "render.shots_name_help": "Унесите јединствен назив за тренутни кадар.", "render.shots_no_result": "Још нема рендера", @@ -3052,6 +3426,7 @@ "select an axis": "изаберите осу", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "прво изаберите осу, праву или кружну ивицу, или лице цилиндра, конуса или торуса", "select bodies first": "прво изаберите тела", + "select components first": "прво изаберите компоненте", "select corner points, or the two lines of one corner": "изаберите тачке углова или две линије једног угла", "select one corner point, or the two lines of one corner": "изаберите једну тачку угла или две линије једног угла", "select sheet edges first": "прво изаберите ивице лима", @@ -3070,6 +3445,7 @@ "settings.double_click_components": "Двоструки клик на компоненту је активира, а повезану компоненту мења на месту", "settings.frame_rate_details_note": "Додаје график времена последњих кадрова са бројем исцртаних троуглова, хистограм времена кадрова и шта је GPU нацртао. Кликните број кадрова у секунди у приказу да их прикажете или сакријете.", "settings.frame_rate_note": "Приказује број кадрова у секунди у доњем десном углу приказа.", + "settings.insert_lists_open_designs": "Уметни дизајн прво наводи дизајне отворене у другим картицама", "settings.measure_axis_distances": "Прикажи растојања X/Y/Z", "settings.measure_axis_distances_help": "Приказуј компоненте растојања по светским осама уз укупно растојање док је Мерење отворено.", "settings.msaa_applies_on_restart": "Углађивање ивица примењује се при следећем покретању SolidCraft-а", @@ -3094,6 +3470,10 @@ "shortcut.unpin": "Откачи", "sketch is missing": "скица недостаје", "sketch.degrees_of_freedom": "Степени слободе: {count}", + "sketch.dimension_added_driven": "Скица већ одређује ту коту, па је додата као изведена (референтна) кота", + "sketch.dimension_applied": "Кота је примењена", + "sketch.dimension_applied_defined": "Кота је примењена; параметар `{name}` је дефинисан", + "sketch.dimension_applied_renamed": "Кота је примењена; параметар је назван `{name}`", "sketch.dof_left": "Преостали степени слободе: {count}", "sketch.dof_left_one": "Преостали степени слободе: {count}", "sketch.draw_curves": "Цртај као {linetype}", @@ -3128,6 +3508,8 @@ "sketch_text.use_font_file": "Користи датотеку фонта {path}", "solid": "пуно", "status.editing": "Измена: {name}", + "status.navigation": "Мапирање миша: {mapping}", + "status.navigation_tip": "Како миш помера приказ. Кликните да изаберете друго мапирање", "status.under_constrained": "Недовољно ограничено (степени слободе: {count})", "status.under_constrained_one": "Недовољно ограничено ({count} степен слободе)", "status.units_tip": "Јединице дизајна. Кликните да их промените", @@ -3161,8 +3543,10 @@ "timeline.delete_folders_tip": "Задржи њихове операције и угнежђене фасцикле у временској траци", "timeline.delete_folders_with_features_tip": "Избриши фасцикле са свим операцијама у њима и њихове угнежђене фасцикле", "timeline.delete_many": "Избриши изабране операције: {count}", + "timeline.edited_rolled_back": "Операција {name} је промењена, али је после ознаке враћања, па није изграђена: померите ознаку иза ње да видите резултат", "timeline.feature_error": "Грешка: {message}", "timeline.feature_hint": "Двапут кликните за измену · превуците за промену редоследа · десни клик за више", + "timeline.feature_rolled_back": "Враћено уназад: после ознаке враћања, па није изграђено", "timeline.feature_warning": "Упозорење: {message}", "timeline.group": "Нова фасцикла операција", "timeline.move_many": "Премести ставке временске траке", @@ -3244,6 +3628,10 @@ "ui.3d_mouse_offer": "Калибрација 3D миша исправља замењене или обрнуте осе и подешава његову брзину и осећај.", "ui.3d_mouse_offer_never": "Не приказуј више", "ui.3d_mouse_offer_swap": "Пребаци на Hidraw (директни драјвер)", + "ui.3d_mouse_orbit": "Орбитирање", + "ui.3d_mouse_orbit_classic": "Класично", + "ui.3d_mouse_orbit_tip": "Праћено: 3D миш окреће приказ око дела модела који је у средини приказа када окретање почне, означеног бледом куглом. Класично: око центра приказа", + "ui.3d_mouse_orbit_traced": "Праћено", "ui.3d_mouse_reading": "Очитање", "ui.3d_mouse_reset_axes": "Ресетуј доделу оса", "ui.3d_mouse_reset_view": "Ресетуј приказ", @@ -3280,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Закрените капицу супротно од казаљке на сату, држите је, а затим пустите.", "ui.3d_mouse_switch_cap": "Калибриши као Повратак у центар", "ui.3d_mouse_switch_trackball": "Калибриши као трекбол", + "ui.3d_mouse_use_direct": "Користи директни драјвер", + "ui.3d_mouse_use_direct_tip": "Читај 3D миш директно (hidraw) уместо преко spacenavd.", "ui.3d_mouse_view_axis": "Оса приказа", "ui.3d_mouse_view_off": "Искључено", "ui.3d_mouse_view_off_tip": "Профил драјвера 3D миша искључује ову осу, па њено кретање не помера приказ. Избор кретања друге осе овде их замењује, а то кретање престаје да помера приказ.", @@ -3596,6 +3986,7 @@ "ui.circle": "Круг", "ui.circular_pattern": "Кружни низ", "ui.clear": "Очисти", + "ui.clear_print_face": "Уклони површину за штампу", "ui.clear_recent": "Очисти недавно", "ui.clear_the_selection": "Поништи избор", "ui.click_selects_whole_bodies": "Клик бира цела тела", @@ -3630,6 +4021,15 @@ "ui.constant": "Константно", "ui.constraints_conflict": "Ограничења су у сукобу", "ui.construction": "Помоћна", + "ui.construction.angle_hint": "Помоћна права: кликните тачку, па упишите угао или изаберите смер", + "ui.construction.bisector_hint": "Симетрала: изаберите две праве линије које се секу, па жељено решење", + "ui.construction.common_hint": "Заједничка тангента: изаберите два круга или лука, па жељено решење", + "ui.construction.contact_hint": "Тангента: изаберите круг/лук или отворени сплајн/конику, па тачку додира (криве слободног облика: крајње тачке)", + "ui.construction.external_hint": "Тангента: изаберите круг или лук, спољашњу тачку, па жељено решење", + "ui.construction.free_hint": "Помоћна права: кликните две тачке; десним кликом почните испочетка", + "ui.construction.point_hint": "Помоћна права: кликните тачку кроз коју пролази", + "ui.construction.reference_hint": "Помоћна права: изаберите праву линију, па тачку; десним кликом промените референцу", + "ui.construction.ribbon_label": "Пом. праве", "ui.context_menu": "Контекстни мени", "ui.continuity_g0": "G0 прегиб", "ui.continuity_g1": "G1 тангентно", @@ -3670,6 +4070,7 @@ "ui.design_properties.part_number": "Број дела", "ui.design_properties.project": "Пројекат", "ui.design_properties.revision": "Ревизија", + "ui.design_saved": "Сачувано: {path}", "ui.diameter": "Пречник", "ui.dimension": "Кота", "ui.direct_interaction": "Директна интеракција", @@ -3720,6 +4121,14 @@ "ui.end_scale": "Крајње скалирање", "ui.entities": "Елементи", "ui.equal": "Једнакост", + "ui.equation_end": "Крај", + "ui.equation_explicit": "Експлицитни", + "ui.equation_form": "Облик", + "ui.equation_note": "Променљива је обичан број: sin(t) је узима у радијанима. Резултат без јединице је у јединици дизајна.", + "ui.equation_parametric": "Параметарски", + "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.exportDxf": "Извези DXF…", @@ -3785,6 +4194,7 @@ "ui.heading_design": "ДИЗАЈН", "ui.heading_draft": "НАГИБ", "ui.heading_edit": "ИЗМЕНИ", + "ui.heading_equation_curve": "КРИВА ПО ЈЕДНАЧИНИ", "ui.heading_extrude": "ЕКСТРУДИРАЈ", "ui.heading_fillet": "ЗАОБЉЕЊЕ", "ui.heading_finish_editing": "ЗАВРШИ ИЗМЕНУ", @@ -3876,6 +4286,10 @@ "ui.import_ellipsis": "Увези…", "ui.infinite": "Бесконачно", "ui.insert": "Уметни", + "ui.insert_design.browse": "Прегледај…", + "ui.insert_design.open_designs": "Отворени дизајни", + "ui.insert_design.saved_file": "Дизајн {name} има несачуване промене, па је уметнута његова сачувана датотека. Сачувајте га, а затим покрените Ажурирај везе да бисте унели промене.", + "ui.insert_design.unsaved": "Несачуване промене", "ui.inspect": "Прегледај", "ui.intersect": "Пресек", "ui.isolate": "Изолуј", @@ -4019,12 +4433,29 @@ "ui.polygon": "Полигон", "ui.preferences": "Подешавања", "ui.press_a_key": "притисните тастер…", + "ui.print_face_cleared": "{body}: површина за штампу уклоњена", + "ui.print_face_input": "Површина за штампу", + "ui.print_face_one_body": "Површина за штампу припада једном телу: пошаљите то тело само да бисте је променили", + "ui.print_face_pick": "Изаберите равну површину пуног тела да постане површина за штампу", + "ui.print_face_set": "{body}: површина за штампу постављена; Пошаљи у слајсер полаже тело на њу", "ui.profile": "Профил", "ui.profile_plane": "Раван профила", "ui.profiles": "Профили", "ui.project": "Пројектуј", "ui.project_face_edges_sketch": "Пројектуј ивице лица у нове скице", "ui.properties": "Својства", + "ui.properties.copy_matrix": "Копирај матрицу", + "ui.properties.first_moments": "Статички моменти", + "ui.properties.inertia": "Момент инерције", + "ui.properties.inertia_note": "У односу на центар масе, у светским осама (g·{unit}²)", + "ui.properties.inertia_unavailable": "Није доступно: мрежа једног тела није затворена.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Мерење…", + "ui.properties.measuring_failed": "Мерење је заустављено пре завршетка.", + "ui.properties.principal_axis": "Главна оса {number}", + "ui.properties.principal_moments": "Главни моменти", "ui.pull_direction": "Смер вађења из калупа", "ui.quality": "Квалитет", "ui.quantity": "Број", @@ -4070,6 +4501,7 @@ "ui.revolve": "Ротирај", "ui.ribbon_exit": "Изађи", "ui.ribbon_labels": "Прикажи текст испод икона на траци", + "ui.ribbon_menus_skip_pinned": "Изостави прикачене команде из падајућих менија", "ui.ribbon_pin_footer": "Прикачене команде приказују се на траци. Задржите показивач изнад реда да га прикачите или откачите.", "ui.ribbon_workspace": "Радни простор", "ui.right": "Десно", @@ -4087,6 +4519,7 @@ "ui.select": "Изабери", "ui.selectOther": "Изабери друго…", "ui.selectTangentChain": "Изабери тангентни ланац", + "ui.select_all": "Изабери све", "ui.selection": "Избор", "ui.selection_modifiers": "Модификатори избора", "ui.selection_shift_add_only": "Shift+клик додаје без уклањања", @@ -4123,7 +4556,11 @@ "ui.sketch": "Скица", "ui.sketch_dimension": "Кота скице", "ui.sketch_grid": "Мрежа скице", + "ui.sketch_intersect_geometry": "Геометрија", + "ui.sketch_intersect_projection_link": "Веза пројекције", + "ui.sketch_intersect_selection_filter": "Филтер избора", "ui.sketch_modifiers": "Модификатори скице", + "ui.sketch_project_geometry": "Геометрија", "ui.sketch_scale": "Скалирање скице", "ui.sketches": "Скице", "ui.slice": "Пресеци", @@ -4170,6 +4607,11 @@ "ui.symmetric": "Симетрично", "ui.symmetry": "Симетрија", "ui.system": "Систем", + "ui.tab_assemble": "Склоп", + "ui.tab_manage": "Управљање", + "ui.tab_sheet_metal": "Лим", + "ui.tab_view": "Приказ", + "ui.tab_weldments": "Заварене конструкције", "ui.tangent": "Тангентно", "ui.taper_angle": "Угао сужења", "ui.tapped": "Са навојем", @@ -4185,6 +4627,7 @@ "ui.tip_angle": "Угао врха", "ui.to_object": "До објекта", "ui.tool_direction": "Смер алата", + "ui.toolbar_tabs_hidden": "Све картице траке са алатима су скривене. Кликните + или кликните десним дугметом овде да прикажете неку.", "ui.top": "Одозго", "ui.top_diameter": "Горњи пречник", "ui.top_radius": "Горњи полупречник", @@ -4222,6 +4665,8 @@ "ui.view_centre": "Центар приказа", "ui.viewport_status_clear": "Искључи {name}", "ui.viewport_status_clear_hint": "Искључи {name} · Esc", + "ui.viewport_status_edges": "Ивица: {count}", + "ui.viewport_status_from": "из смера {direction}", "ui.viewport_status_key": "Прикажи легенду за {name}", "ui.viewport_status_minimum_radius": "Минимални полупречник", "ui.viewport_status_pairs": "Парова: {count}", @@ -4231,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Осенчено са ивицама", "ui.visual_style": "Визуелни стил", "ui.visual_wireframe": "Жичани модел", + "ui.wheel_zoom_speed": "Брзина зумирања точкићем миша", "ui.width": "Ширина", "ui.window_system": "Систем прозора", "ui.window_system_auto": "Аутоматски", @@ -4261,7 +4707,9 @@ "update.button": "Ажурирај", "update.button_version": "Ажурирај на {version}", "update.check_automatically": "Аутоматски проверавај ажурирања", + "update.check_for_updates": "Провери ажурирања", "update.check_now": "Провери сада", + "update.check_unavailable": "Провера ажурирања није доступна за ову инсталацију.", "update.checking": "Провера…", "update.copy": "Копирај", "update.dmg_opened": "Слика диска је отворена и проверена: превуците SolidCraft у Applications, замените стару копију, а затим поново покрените SolidCraft.", @@ -4284,6 +4732,9 @@ "update.prefs_managed": "Ово је SolidCraft {version}, којим управља {manager}. Провера пита GitHub за најновије издање и не шаље ништа друго; ажурирајте SolidCraft у {manager}.", "update.prefs_note": "Ово је SolidCraft {version} ({kind}). Провера пита GitHub за најновије издање и не шаље ништа друго.", "update.prefs_web": "Поново учитајте страницу да добијете најновију веб апликацију. Ако мрежа није доступна или се ажурирање не може преузети на време, отвара се сачувана верзија; преузимање у току наставља се за следеће поновно учитавање.", + "update.refresh_checking": "Провера најновијег издања…", + "update.refresh_fallback": "Није било могуће проверити да ли постоји новије издање; ажурирање на верзију {version}, пронађену раније.", + "update.refresh_selected": "Ажурирање на {version}.", "update.release_page": "Отвори страницу издања", "update.restart": "Поново покрени сада", "update.restart_later": "Касније", diff --git a/assets/locales/sv.json b/assets/locales/sv.json index f5b76fd426..46e0a79870 100644 --- a/assets/locales/sv.json +++ b/assets/locales/sv.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Konstruktioner du öppnar eller sparar visas här.", "Determined by To Object. Choose Angle to edit the angular extent.": "Bestäms av Till objekt. Välj Vinkel för att redigera vinkelutsträckningen.", "Diameter": "Diameter", + "Did this assembly open the wrong way up?": "Öppnades den här sammanställningen med fel sida upp?", + "Did this part open the wrong way up?": "Öppnades den här delen med fel sida upp?", "Dimension new shapes": "Måttsätt nya former", "Direction": "Riktning", "Discard": "Förkasta", @@ -204,6 +206,7 @@ "Edges": "Kanter", "Edit": "Redigera", "Edit Profile Sketch": "Redigera profilskiss", + "Edit shortcuts": "Redigera genvägar", "Enable rule": "Aktivera regel", "Enabled": "Aktiverad", "End Radius": "Slutradie", @@ -466,6 +469,7 @@ "Radius": "Radie", "Radius Fit": "Radieanpassning", "Ratio": "Förhållande", + "Read-only list: shortcuts can't be changed here.": "Skrivskyddad lista: genvägar kan inte ändras här.", "Reattach selected face": "Återfäst markerad yta", "Recover": "Återställ", "Recover unsaved design?": "Återställ osparad konstruktion?", @@ -599,6 +603,7 @@ "Square": "Kvadrat", "Stand it up": "Res upp den", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Res upp den: ett kvartsvarv runt X, som en enda Flytta-funktion som du kan ångra.", + "Stand this assembly up?": "Resa upp denna sammanställning?", "Stand this part up?": "Resa upp denna del?", "Start angle": "Startvinkel", "Start bulge": "Startbukt", @@ -649,6 +654,7 @@ "Third point": "Tredje punkten", "This child is shared. Deleting it removes it from every copy of its parent.": "Det här underordnade objektet är delat. Om du tar bort det försvinner det från alla kopior av dess överordnade.", "This child is shared. Position changes apply to every copy of its parent.": "Det här underordnade objektet är delat. Lägesändringar gäller för alla kopior av dess överordnade.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Den här komponenten ligger i en undersammanställning som är placerad mer än en gång: om du justerar den flyttas den i varje placering.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Den här ytan kan inte ge ett beständigt fäste. Välj en annan yta eller använd uttryckligen ett fast plan.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Den här bilden är inte bifogad på GitHub än. Kopiera eller spara den och bifoga den sedan på GitHub.", "Thread depth": "Gängdjup", @@ -769,6 +775,8 @@ "at the face": "vid ytan", "bom.group_children": "Underdelar i sin dels rad", "browser.body_name": "Kropp{number}", + "browser.calibrate_click_a": "Klicka på bilden för punkt A eller skriv dess koordinater.", + "browser.calibrate_click_b": "Klicka på bilden för punkt B eller skriv dess koordinater.", "browser.calibrate_title": "Kalibrera: {name}", "browser.capture_many": "{count} komponenter flyttades men deras lägen har inte fångats.", "browser.capture_note": "Fånga behåller det nya läget; Återställ sätter tillbaka det.", @@ -790,6 +798,11 @@ "cam.roller": "Rulle", "cam.roller_pick": "Rulle (valfritt)", "cam.tip_pick": "Spets (valfritt)", + "camera.shot_pitch": "Lutning", + "camera.shot_roll": "Rullning", + "camera.shot_yaw": "Girning", + "canvas.decal_place_hint": "Klicka på en yta för att placera dekalen", + "canvas.place_hint": "Klicka på ett plan eller en yta för att placera duken", "choose a profile": "välj en profil", "choose the cam's and the follower's joints": "välj kamens och följarens leder", "choose the joints to couple": "välj lederna som ska kopplas", @@ -797,12 +810,16 @@ "click sketch curves": "klicka på skisskurvor", "cmd.appearance_assign": "Utseende", "cmd.appearance_chain": "Utseendekedja", + "cmd.appearance_copy": "Kopiera utseende", + "cmd.appearance_favorite": "Favoritutseende", "cmd.appearance_library": "Utseendebibliotek", "cmd.appearance_list": "Lista utseenden", "cmd.appearance_paint": "Måla utseende", + "cmd.appearance_paste": "Klistra in utseende", "cmd.appearance_pick_up": "Plocka upp utseende", "cmd.appearance_replace": "Ersätt utseende", "cmd.assembly_bom": "Stycklista", + "cmd.body.print_face": "Utskriftsyta", "cmd.body_rename": "Byt namn på kropp", "cmd.browser_collapse_all": "Fäll ihop alla", "cmd.browser_expand_all": "Expandera alla", @@ -810,6 +827,7 @@ "cmd.browser_move": "Flytta webbläsarobjekt", "cmd.browser_order": "Ordna webbläsarobjekt", "cmd.browser_rename_group": "Byt namn på grupp", + "cmd.browser_reparent": "Flytta till komponent", "cmd.browser_ungroup": "Dela upp grupp", "cmd.browser_visibility": "Visa/Dölj", "cmd.canvas_calibrate": "Kalibrera duk", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Delegenskaper", "cmd.component_metadata_set": "Ändra delegenskaper", "cmd.component_move_bodies": "Flytta kroppar till komponent", + "cmd.component_move_features": "Flytta funktioner till komponent", "cmd.component_move_sketches": "Flytta skisser till komponent", + "cmd.component_paste_from_design": "Klistra in från en annan konstruktion", "cmd.component_paste_new": "Klistra in ny", "cmd.component_properties": "Komponentens fysiska egenskaper", "cmd.component_relink": "Länka om källa", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Rörelsespår", "cmd.motion_trace_edit": "Redigera rörelsespår", "cmd.motion_trace_keep": "Behåll rörelsespår", + "cmd.occurrence_align": "Justera förekomst", "cmd.occurrence_copy": "Klistra in (instans)", "cmd.occurrence_drag": "Dra förekomst", "cmd.occurrence_ground": "Fixera", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Ta bort ytbindning", "cmd.render_library_targets": "Hitta mål i renderingsbiblioteket", "cmd.render_lights_add": "Lägg till ljus", + "cmd.render_lights_duplicate": "Duplicera renderingsljus", + "cmd.render_lights_list": "Lista renderingsljus", + "cmd.render_lights_mute": "Stäng av ljus", + "cmd.render_lights_remove": "Ta bort renderingsljus", + "cmd.render_lights_set": "Ange renderingsljus", "cmd.render_lights_solo": "Solo-ljus", "cmd.render_optics_bind": "Spara optikbindning", "cmd.render_optics_unbind": "Ta bort optikbindning", "cmd.render_output_size": "Ange utdatastorlek för rendering", "cmd.render_preflight": "Kontrollera renderingsbegäran", + "cmd.render_preflight_copy": "Kopiera rapport från renderingskontroll", "cmd.render_preset_apply": "Tillämpa inbyggd studio", "cmd.render_preset_resolve": "Granska inbyggd studio", "cmd.render_quality_prepare": "Granska fast samplingsbudget", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Rendera bild", "cmd.render_status": "Renderingsförlopp", "cmd.select_clear": "Rensa markering", + "cmd.settings_profile_apply": "Tillämpa inställningsprofil", + "cmd.settings_profile_delete": "Ta bort inställningsprofil", + "cmd.settings_profile_export": "Exportera inställningsprofil", + "cmd.settings_profile_import": "Importera inställningsprofil", + "cmd.settings_profile_list": "Inställningsprofiler", + "cmd.settings_profile_rename": "Byt namn på inställningsprofil", + "cmd.settings_profile_save": "Spara inställningsprofil", "cmd.sheet_convert": "Konvertera till plåt", "cmd.sheet_export_bend_table": "Exportera böjtabell", "cmd.sheet_export_dxf": "Exportera plant mönster som DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Vik igen", "cmd.sheet_rules": "Lista plåtregler", "cmd.sheet_unfold": "Vik ut", + "cmd.sketch.construction_line": "Konstruktionslinje", + "cmd.sketch.construction_line.angle": "Konstruktionslinje med vinkel", + "cmd.sketch.construction_line.bisector": "Vinkelbisektris", + "cmd.sketch.construction_line.horizontal": "Horisontell konstruktionslinje", + "cmd.sketch.construction_line.parallel": "Parallell konstruktionslinje", + "cmd.sketch.construction_line.perpendicular": "Vinkelrät konstruktionslinje", + "cmd.sketch.construction_line.tangent_at": "Tangent i kurvpunkt", + "cmd.sketch.construction_line.tangent_external": "Tangent genom yttre punkt", + "cmd.sketch.construction_line.tangent_two": "Gemensam tangent", + "cmd.sketch.construction_line.vertical": "Vertikal konstruktionslinje", "cmd.sketch_arc3d": "3D-båge", "cmd.sketch_arc_center_point": "Båge med centrumpunkt", "cmd.sketch_arc_tangent": "Tangentbåge", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Ritningsstorlek", "cmd.sketch_edit": "Redigera skiss", "cmd.sketch_edit_text": "Redigera text", + "cmd.sketch_equation_curve": "Ekvationskurva", + "cmd.sketch_equation_curve_edit": "Redigera ekvationskurva", "cmd.sketch_export_dxf": "Spara som DXF", "cmd.sketch_finish_auto_constrain": "Slutför med Autovillkor", "cmd.sketch_fit_curves_to_section": "Anpassa kurvor till mesh-sektion", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "Renderingswebbläsare", "cmd.view.render_dock": "Flytta renderingspanel", "cmd.view.render_focus": "Fokusvy för rendering", + "cmd.view.status_items": "Statusobjekt i vyfönstret", "cmd.view_ambient_occlusion": "Omgivningsocklusion", "cmd.view_angle_readout": "Vyvinklar", "cmd.view_apply": "Återställ namngiven vy", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Normalt mot markering", "cmd.view_previous_tab": "Föregående flik", "cmd.view_rename": "Byt namn på namngiven vy", + "cmd.view_render_frame": "Hjälplinjer för renderingsram", "cmd.view_right": "Vy från höger", "cmd.view_save": "Ny namngiven vy", "cmd.view_shots_strip": "Tagningsremsa", @@ -1229,10 +1277,14 @@ "color.copy": "Klicka för att kopiera färgvärden", "color.edit": "Klicka för att redigera färg", "color.gamma": "Visar färgvärden i gammarymd 0–255", + "color.hex": "Hex", + "color.hex_help": "Ange #rrggbb eller #rrggbbaa; sex siffror behåller opaciteten", + "color.hex_hint": "#rrggbb", "color.hue": "Nyans", "color.linear": "Visar färgvärden i linjär rymd 0–1", "color.saturation_value": "Mättnad och värde", "color.selected": "Vald färg", + "config.column_filter_empty": "Inga matchande kolumner", "contact.components_count": "{count} komponenter", "contact.enabled_label": "aktiverad", "coupling.gear_1": "Kugghjul 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Klicka på ett annat styrande mått för att infoga dess namn", "dialog.dimension_name": "Mått: {name}", "dialog.edit_title": "REDIGERA {title}", + "dialog.no_shown_body": "Ingen synlig kropp att ändra: visa en, eller välj Ny kropp", "dialog.select": "↖ Markera", "dialog.selected_count": "↖ {count} markerade", "dialog.selection_guidance": "Markera en indata för att ändra den. Klicka på markerade objekt för att ta bort dem; Ctrl/Cmd-klick växlar dem också. Granska förhandsvisningen och tryck sedan på OK.", @@ -1346,8 +1399,20 @@ "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.mit": "MIT-licens", "help.notice": "NOTICE", + "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.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.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.", + "help.sketch.construction_line.horizontal": "Skapa en oändlig horisontell konstruktionslinje genom en punkt. Det horisontella villkoret gäller även efter senare ändringar.", + "help.sketch.construction_line.parallel": "Välj en rak skisslinje och placera sedan en parallell, oändlig konstruktionslinje. Högerklicka för att välja en annan referens.", + "help.sketch.construction_line.perpendicular": "Välj en rak skisslinje och placera sedan en vinkelrät, oändlig konstruktionslinje. Högerklicka för att välja en annan referens.", + "help.sketch.construction_line.tangent_at": "Skapa en tangent i en punkt på en cirkel eller cirkelbåge, eller i en ändpunkt på en öppen spline eller konisk kurva. Välj kurvan och sedan beröringspunkten.", + "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.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.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.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.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.", @@ -1374,6 +1439,8 @@ "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.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.", + "history.sketch_under_constrained": "Underbestämd ({count} frihetsgrader)", + "history.sketch_under_constrained_one": "Underbestämd ({count} frihetsgrad)", "hole_presets.confirm_delete": "Bekräfta borttagning", "hole_presets.delete": "Ta bort", "hole_presets.delete_prompt": "Ta bort den här personliga förinställningen? Befintliga hål påverkas inte.", @@ -1474,6 +1541,21 @@ "joint.one_component": "Dessa delar är kroppar i samma komponent: {bodies}. En led flyttar en komponent mot en annan, så varje del behöver en egen komponent.", "joint.rigid_group_one_component": "De här {count} kropparna ligger alla i en enda komponent (eller i själva konstruktionen): använd {command} först.", "joint.rigid_group_select_components": "Markera två eller flera komponenter.", + "joint_move.blocked_hint": "Kontrollera ledtypen (till exempel Styv), fixering, ledgränser och parameterstyrda värden.", + "joint_move.blocked_title": "Det går inte att flytta den här delen", + "joint_move.coarse": "Rotationssteg nära axeln (°)", + "joint_move.distance": "Avstånd från start", + "joint_move.done": "Klar", + "joint_move.fine": "Rotationssteg långt från axeln (°)", + "joint_move.fixed": "Den här delen har ingen rörlig ledaxel.", + "joint_move.help": "Flytta — dra en pil eller ring. Esc avbryter det aktuella draget.", + "joint_move.legacy_hint": "Stäng av för att använda den tidigare Flytta-panelen.", + "joint_move.precision": "Dra utanför den streckade hjälplinjen för finare rotation.", + "joint_move.rotation": "Rotation från start", + "joint_move.select": "Markera en yta eller en del i sammanställningen och välj sedan Flytta.", + "joint_move.settings": "Flytta i sammanställning", + "joint_move.step": "Steg", + "joint_move.use_handles": "Använd handtag för ledrörelse vid Flytta i sammanställning", "keymap.command_id": "Kommando-ID", "keymap.empty": "Inga matchande kommandon", "keymap.use_anyway": "Använd {key} här ändå", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+vänsterdrag orbiterar, Alt+mittknapp panorerar, Alt+högerknapp zoomar; mittknappsdrag panorerar också", "nav.openscad.help": "Vänsterdrag på tomt utrymme orbiterar, Shift+vänsterdrag markerar med ruta, drag på skissgeometri redigerar; höger- eller mittdrag panorerar, Shift+höger- eller Shift+mittdrag zoomar", "nav.perspective.sketch": "Skissredigering använder ortografisk vy. Slutför skissen för att växla till perspektiv.", + "nav.right_drag_orbits.help": "Av: ett högerdrag påverkar inte kameran och högerknappen öppnar bara snabbmenyn. Om du väljer musmappningen SolidWorks stängs det av.", + "nav.right_drag_orbits.label": "Högerdrag orbiterar", "nav.solidworks.help": "Mittdrag orbiterar, Ctrl+mittdrag panorerar, Shift+mittdrag zoomar, högerdrag orbiterar", "nav.tinkercad.help": "Högerdrag orbiterar, mitt- eller Shift+högerdrag panorerar", "nav.touchpad.help": "Håll Shift och flytta pekaren för att panorera; håll Alt/Option och flytta den för att orbitera. Ingen musknapp behövs. Vertikal rullning zoomar.", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "Anpassa navigeringsfältet…", "navbar.custom.design": "Konstruktion", "navbar.custom.duplicate": "Den här platsen finns redan i fältet.", + "navbar.custom.edge": "Kant", + "navbar.custom.edge_bottom": "Nederst", + "navbar.custom.edge_top": "Överst", "navbar.custom.fade": "Tona", "navbar.custom.full": "Ta bort en plats för att lägga till en annan.", "navbar.custom.hidden_design": "Dold i Konstruktion", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "Tjocklek", "parts.dimension.width": "Bredd", "parts.family.bearing": "Spårkullager (ytterform)", + "parts.family.button_head_cap_screw": "Kullrig insexskruv", "parts.family.dowel_pin": "Cylindriskt stift (passtift)", "parts.family.hex_bolt": "Sexkantsbult / sexkantsskruv", "parts.family.hex_nut": "Sexkantsmutter", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "Jämnar ut hackiga kanter.", "prefs.applies_on_restart": "Tillämpas vid omstart", "prefs.assistant": "Assistent", + "prefs.auto_operation": "Välj operation automatiskt", + "prefs.auto_operation_note": "Funktionsdialoger väljer Sammanfoga, Ny kropp eller Klipp ut utifrån vad det nya materialet rör vid, tills du väljer själv. Av: de behåller den operation de öppnas med.", "prefs.axes": "Axlar", "prefs.axis": "Axel", "prefs.bodies_folders": "Kroppsmappar", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "Nere i mitten", "prefs.bottom_right": "Nere till höger", "prefs.browser": "Webbläsare", + "prefs.browser_eye_left": "Ögat bredvid expanderingspilen", + "prefs.browser_eye_left_note": "Raderna i webbläsaren visar sitt visa/dölj-öga bredvid expanderingspilen, som tidigare, i stället för i en kolumn längst till höger i raderna.", + "prefs.browser_reveal_on_hover": "Visa vid hovring", + "prefs.browser_show_note": "Visas håller webbläsaren på skärmen. Visa vid hovring håller den dold tills pekaren kommer över dess plats eller den smala fliken vid dess vänstra kant, och den förblir öppen medan pekaren är inuti den eller en meny från den är öppen.", + "prefs.browser_shown": "Visas", "prefs.built_in": "Inbyggd", "prefs.button_left": "Dra med vänsterknapp", "prefs.button_middle": "Dra med mittknapp", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "Träder i kraft efter att SolidCraft startats om. WGPU_ADAPTER_NAME åsidosätter detta val.", "prefs.graphics_note": "Avväg hastighet mot jämnhet. Varje val visar sin effekt på ett exempel.", "prefs.graphics_presets_note": "Förinställningar anger valen nedan tillsammans.", + "prefs.hide_constraint_glyphs_zoomed_out": "Dölj villkorssymboler vid utzoomning", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Villkorssymbolerna försvinner från vyn när skissen zoomas ut så långt att de skulle skräpa ned den. Av: de behåller en fast storlek på skärmen vid varje zoomnivå.", "prefs.history": "Historik", "prefs.history_origin_bodies": "Visa origo och kroppar överst", "prefs.history_origin_bodies_note": "Historiken till vänster eller under webbläsaren börjar med den aktiva komponentens mapp Kroppar och Origo, med sina ögon och menyer som i webbläsaren. Historiken längst ned är oförändrad.", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "Kompakt dockar paneler mot fönsterkanterna. Genomskinlig webbläsare finns i Flytande. Ändra assistentens sida på sidan Assistent.", "prefs.left": "Vänster", "prefs.left_click": "Vänsterklick", + "prefs.line_drag_draws_one": "Tryck och dra ritar en linje", + "prefs.line_drag_draws_one_note": "I verktyget Linje ritar ett tryck, ett drag och ett släpp en linje från där du tryckte till där du släppte, och där slutar den. Klick ritar fortfarande en kedja. Av: ett drag placerar en punkt där det släpps, som ett klick gör.", "prefs.linked_designs": "Länkade konstruktioner", "prefs.live_input": "Liveinmatning", "prefs.live_preview": "Liveförhandsvisning", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "STEP- och importfiler", "prefs.max_file_size_web": "Webbappen öppnar STEP- och importfiler upp till 512 MB och konstruktioner upp till 256 MB: en webbläsarflik kan inte rymma större filer. Gränserna kan höjas i skrivbordsappen.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Stora ikoner", + "prefs.menu_list": "Lista", + "prefs.menus": "Menyer", "prefs.minutes": "minuter", "prefs.minutes_suffix": " min", "prefs.mouse": "Mus och styrplatta", @@ -1853,14 +1955,40 @@ "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.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", "prefs.placement": "Placering", "prefs.prefer_free_sketch_points": "Föredra flyttbara skisspunkter när punkter överlappar", "prefs.preview_choices": "Förhandsvisning av dina val", + "prefs.print_face_highlight": "Markering av utskriftsyta", + "prefs.print_face_highlight_tip": "Hur starkt en kropps utskriftsyta färgas och konturmarkeras i vyn. 0 % visar ingen markering.", + "prefs.profile_active": "Aktiv profil", + "prefs.profile_applied": "Profilen tillämpades.", + "prefs.profile_changed": "Inställningarna har ändrats sedan profilen sparades. Spara behåller dem i den.", + "prefs.profile_deleted": "Profilen togs bort.", + "prefs.profile_export": "Exportera…", + "prefs.profile_exported": "Profilen exporterades.", + "prefs.profile_imported": "Profilen importerades. Välj den under Aktiv profil för att byta till den.", + "prefs.profile_name_hint": "Profilnamn, t.ex. Bärbar dator", + "prefs.profile_rename": "Byt namn", + "prefs.profile_renamed": "Profilen fick ett nytt namn.", + "prefs.profile_save": "Spara", + "prefs.profile_saved": "De aktuella inställningarna sparades i profilen.", + "prefs.profile_skipped": "Profilen tillämpades; {n} inställning(ar) behöll sitt nuvarande värde (se loggen).", + "prefs.profiles": "Inställningsprofiler", + "prefs.profiles_note": "Genvägar, verktygsfältet, navigering, radialmenyn och gränssnittsinställningar, samlade under ett namn och växlade på en gång.", + "prefs.project_options_dialog": "Dialog med alternativ för Projicera", + "prefs.project_options_dialog_note": "På: välj Geometri eller Kroppar och Projektionslänk innan du projicerar. Av: behåll det tidigare arbetsflödet med direktval.", "prefs.quality_default": "standard", "prefs.quality_fastest": "snabbast", "prefs.quality_slowest": "långsammast", "prefs.quality_smoother": "jämnare", + "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", + "prefs.ribbon_reveal": "Visa sökresultat i menyfliksområdet", + "prefs.ribbon_reveal_note": "När du håller pekaren över ett resultat i Sök kommandon visas var kommandot finns i menyfliksområdet: dess flik, dess knapp eller dess rad i en panels rullgardinsmeny. Ett kommando som startas från sökningen blinkar där, och ett pågående kommando från en rullgardinsmeny markerar sin panel.", "prefs.right_click": "Högerklick", "prefs.saving": "Autosparande och återställning", "prefs.scale_note": "Även Ctrl + / Ctrl − i fönstret. Knapparna är 28 px vid 100 %.", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "Inga matchande inställningar", "prefs.select": "Markera", "prefs.select_one_item": "Markera ett objekt", + "prefs.select_other_hidden": "Markera dold geometri med Markera annan", + "prefs.select_other_hidden_help": "På: ta med dolda kroppar, skisser och skymda kanter nära pekaren. Av: använd den ursprungliga listan över synlig geometri under pekaren.", "prefs.selecting": "Markering", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift lägger bara till; Ctrl / Cmd växlar", "prefs.shift_add_note": "På: Shift lägger till, Ctrl / Cmd växlar. Av (standard): båda växlar.", "prefs.shortcuts": "Genvägar", + "prefs.single_surface_analysis": "Använd en enda ytanalys", + "prefs.single_surface_analysis_help": "När detta är av kan ytskuggning och Kontinuitet vara på samtidigt. Om du slår på detta behålls den senast startade eller ändrade analysen.", + "prefs.sketch_align_points": "Rikta in mot andra skisspunkter vid ritning", + "prefs.sketch_align_points_note": "Vid ritning riktas en punkt in vågrätt mot en skisspunkt och lodrätt mot en annan samtidigt, med en prickad hjälplinje, och klicket behåller båda inriktningarna. Av: endast mot föregående punkt, en axel i taget.", "prefs.sketch_conflict_colour": "Motstridig geometri i konfliktfärgen", "prefs.sketch_conflict_colour_note": "Medan villkoren i en skiss står i konflikt ritas de kurvor och punkter som ett misslyckat villkor binder i konfliktfärgen också, inte bara dess symbol eller mått. Av: de behåller sina vanliga färger.", + "prefs.sketch_construction_lines": "Utökade verktyg för konstruktionslinjer", "prefs.sketch_exit_view": "När en skiss avslutas", "prefs.sketch_exit_view.keep": "Behåll den aktuella vyn", "prefs.sketch_exit_view.previous": "Återgå till föregående vy", "prefs.sketch_exit_view.unless_rotated": "Återgå om inte roterad", "prefs.sketch_exit_view_note": "Dit vyn går efter Slutför skiss, Avbryt eller en dubbel Esc-tryckning.", + "prefs.sketch_open_look_at": "Vrid vyn mot en skiss när den öppnas", + "prefs.sketch_open_look_at_note": "Av: att skapa eller redigera en skiss lämnar vyn som den är. Titta på riktar vyn mot skissen när du vill.", + "prefs.sketch_recent_snap": "Snappa automatiskt till senaste centrum och mittpunkter", + "prefs.sketch_recent_snap_note": "Vid ritning snappar markören dit där hjälplinjerna från de två senaste cirkelcentrumen eller mittpunkterna på raka kanter korsar varandra. Av: hjälplinjerna visas bara och markören flyttas inte.", "prefs.sketch_selection": "Skiss och markering", "prefs.sketch_selection_heading": "Skiss och markering", "prefs.sketch_selection_note": "Hur skisser snappar och hur klick markerar.", + "prefs.sketch_state_icons": "Skissikoner i färgen för sitt tillstånd", + "prefs.sketch_state_icons_note": "I Historiken och webbläsaren tar skissikonen färgen för skissens tillstånd (underbestämd, fullt definierad eller i konflikt), och dess verktygstips anger tillståndet. Av: alla skisser har samma ikon.", "prefs.sketch_tab_always": "Visa alltid fliken Skiss", "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", @@ -1893,6 +2034,11 @@ "prefs.source": "Källa", "prefs.speed": "Hastighet", "prefs.stand_up_note": "Erbjud en kvartsvridning kring X efter att en STEP- eller IGES-fil öppnats med Z uppåt.", + "prefs.stand_up_unknown": "För filer från okända program", + "prefs.stand_up_unknown.ask": "Fråga i en dialog", + "prefs.stand_up_unknown.note": "Visa ett meddelande", + "prefs.stand_up_unknown.off": "Erbjud inte", + "prefs.stand_up_unknown_note": "Hur erbjudandet görs när filens huvud inte anger något program som är känt för att spara med Y uppåt. Ett meddelande visas bara när filen verkar ligga på rygg; Fråga i en dialog frågar om varje sådan fil, som tidigare.", "prefs.status_bar": "Statusfält", "prefs.status_bar_note": "Ett smalt fält längs fönstrets nederkant visar vad som redigeras, den öppna skissens tillstånd i dess färg och konstruktionens enheter; ett klick på enheterna ändrar dem. Skissens tillstånd visas då inte längre i vyns hörn.", "prefs.status_done": "Klar", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "Meddelanden", "prefs.toggle_item": "Lägg till objektet, eller ta bort det igen", "prefs.toolbar": "Verktygsfält", + "prefs.toolbar_tabs": "Verktygsfältsflikar", "prefs.top": "Överst", + "prefs.trackpad_zoom_modifier": "Zooma med tvåfingersrullning medan du håller ned", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Håll ned bara den här tangenten för att zooma mot pekaren med vertikal rullning. Av behåller det befintliga rullningsbeteendet.", + "prefs.trackpad_zoom_modifier.off": "Av", "prefs.translucent": "Halvgenomskinlig", "prefs.under_browser": "Under webbläsaren", "prefs.under_cursor": "Under markören", @@ -1928,13 +2081,24 @@ "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.", "prefs.viewport": "Vyfönster", + "prefs.viewport_analysis_status": "Visa analysstatus i vyfönstret", + "prefs.viewport_analysis_status_help": "Visa aktiva analyser ovanför vyfönstret, öppna deras teckenförklaringar från varje objekt och använd Esc för att stänga av den senaste analysen. Stäng av för att återställa teckenförklaringar i hörnen och det tidigare Esc-beteendet.", + "prefs.viewport_background": "Bakgrund", + "prefs.viewport_background_colour": "Färg", + "prefs.viewport_background_theme": "Temats gradient", "prefs.wheel": "Hjul", "prefs.wheel_trackpad": "Hjul och styrplatta", "prefs.workspace": "Arbetsyta", + "prefs.zoom_stop_at_model": "Stoppa zoomningen vid modellen", + "prefs.zoom_stop_at_model_tip": "3D-musen och enhetsappen zoomar mot ytan mitt i vyn; dragzoom håller kvar punkten där draget började. Av: zoomningen kan gå igenom modellen. Hjul och nyp använder Zooma mot markören separat", "prefs.zoom_toward_cursor": "Zooma mot markören", "properties.title": "Egenskaper: {name}", "recovery.days_ago": "för {count} dagar sedan", "recovery.detail": "{file} · {features} funktioner · autosparad {when}", + "recovery.discard_all": "Förkasta alla", + "recovery.discard_all_detail": "Det går inte att ångra.", + "recovery.discard_all_title": "Förkasta {count} autospar?", + "recovery.discard_all_title_one": "Förkasta 1 autospar?", "recovery.hours_ago": "för {count} h sedan", "recovery.just_now": "nyss", "recovery.min_ago": "för {count} min sedan", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "Vikt", "render.appearance_physics_separate": "Det visuella utseendet tilldelar inte densitet, termiska data eller andra tekniska egenskaper.", "render.appearances": "Utseenden", + "render.assign_help": "Klicka för att tillämpa på markeringen; dra för att förhandsgranska och släpp, eller klicka utan något markerat för att måla.", "render.availability_native": "Inbyggd sammansättning; granska innan du tillämpar", "render.availability_projection": "Stödd projektion; se omfattning och utelämnanden", "render.availability_reference": "Referensdata; inte en automatisk tilldelning", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "Linjärt tröskelvärde för högdagrar", "render.browse_capabilities": "Granska hela funktionskatalogen", "render.browse_environments": "Bläddra bland originella HDR-miljöer", + "render.browse_looks": "Bläddra…", "render.browse_native_scenes": "Bläddra bland inbyggda studior", "render.browse_physical": "Granska fysiska referenser", "render.browse_quality": "Granska kvalitetsspecifikationer", @@ -2080,6 +2246,82 @@ "render.check_opaque": "Transparens: endast ogenomskinliga ytor; bakgrundsalfa stöds.", "render.check_sorted_alpha": "Transparens: sorterad alfaapproximation.", "render.check_transmission": "Transparens: fysisk transmission.", + "render.children.absolute_floor": "Absolut golv", + "render.children.adaptive": "Adaptiv", + "render.children.add_look_reason": "Lägg till en stil för att redigera om den är aktiverad och dess styrka.", + "render.children.all_shots": "Alla tagningar", + "render.children.bloom": "Bloom", + "render.children.check": "Kontrollera", + "render.children.check_now": "Kontrollera nu", + "render.children.chromatic": "Kromatisk", + "render.children.compact": "Kompakt", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopiera", + "render.children.copy_job": "Kopiera jobbet som JSON", + "render.children.copy_request": "Kopiera begäran som JSON", + "render.children.coverage_error": "Täckningsfel", + "render.children.cpu": "CPU-strålspårning", + "render.children.denoise": "Minska brus i den färdiga bilden", + "render.children.depth": "Banedjup", + "render.children.distortion": "Distorsion", + "render.children.edge_samples": "Kantprov", + "render.children.effects_banner": "De här effekterna kräver GPU-vägspårning. Att öppna detta ändrar aldrig metoden.", + "render.children.enable_adaptive": "Aktivera oberoende pilotprov för att redigera de adaptiva gränserna.", + "render.children.enable_bloom": "Aktivera bloom för att redigera dess styrka, tröskelvärde och radie.", + "render.children.finishing": "Efterbehandling", + "render.children.flare": "Reflex", + "render.children.fresnel": "Fresnel-strata", + "render.children.ghosts": "Spökringar", + "render.children.glass": "Glastransport", + "render.children.go_to": "Gå till {editor}", + "render.children.grain": "Korn", + "render.children.halo": "Halo", + "render.children.hdr": "HDR", + "render.children.keep_master": "Behåll rå linjär master", + "render.children.last_result": "Senaste: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Linseffekter", + "render.children.look_summary": "{tone} · {count} stilar", + "render.children.looks": "Kreativa stilar", + "render.children.memory": "Minne", + "render.children.method": "Metod", + "render.children.minimum": "Minimum", + "render.children.needs_cpu": "Kräver CPU-strålspårning. Att öppna detta ändrar aldrig metoden.", + "render.children.nested": "Certifierade nästlade dielektrika", + "render.children.not_checked": "Inte kontrollerad", + "render.children.path": "Vägspårning", + "render.children.pilots": "Pilotprov", + "render.children.preview": "Förhandsvisningsdata", + "render.children.progressive_reason": "Välj Progressiv för att redigera dess antal prov och startvärde.", + "render.children.radius": "Radie", + "render.children.reflect_depth": "Reflektionsdjup", + "render.children.reflect_samples": "Reflektionsprov", + "render.children.regrade": "Ny färgkorrigering", + "render.children.relative_error": "Relativt fel", + "render.children.render": "Rendera", + "render.children.render_shot": "Rendera tagning", + "render.children.renderer": "Renderare", + "render.children.renderers": "Renderare", + "render.children.request": "Den här begäran", + "render.children.reset_finishing": "Återställ efterbehandling", + "render.children.review_quality": "Granska en kvalitetsspecifikation…", + "render.children.rgb_gain": "RGB-förstärkning", + "render.children.samples": "Prov", + "render.children.sampling": "Sampling", + "render.children.saturation": "Mättnad", + "render.children.scene_summary": "{bodies} kroppar · {triangles} trianglar", + "render.children.seed": "Startvärde", + "render.children.sharpening": "Skärpning", + "render.children.single_image": "Enstaka bild", + "render.children.soft_focus": "Mjukt fokus", + "render.children.streak": "Strimma", + "render.children.strength": "Styrka", + "render.children.studio": "Studio", + "render.children.threshold": "Tröskelvärde", + "render.children.tone": "Ton", + "render.children.under_hood": "Under huven", + "render.children.vignette": "Vinjettering", + "render.children.workstation": "Arbetsstation", + "render.choose_look": "Välj först en stil i Utseenden", "render.chromatic_offset": "Kromatisk kantförskjutning", "render.clear_stage": "Ta bort sparad dekor", "render.copy_request": "Kopiera JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "Styrka på högdagerreflexer", "render.flare_threshold": "Scenlinjär tröskel för reflexer", "render.fold_browser": "Fäll ihop webbläsaren", + "render.frame_aspect": "Bildformat", + "render.frame_centre_mark": "Mittmarkering", + "render.frame_copy_limit": "Inget ledigt namn för en duplicerad tagning", + "render.frame_copy_name": "{name} kopia {number}", + "render.frame_custom": "Egen", + "render.frame_distance_unit": " × storlek", + "render.frame_duplicate": "Duplicera", + "render.frame_exposure": "Exponering", + "render.frame_fov": "Synfält", + "render.frame_guides": "Hjälplinjer", + "render.frame_invalid_aspect": "Ogiltig förinställning för bildformat", + "render.frame_lens": "Synfält {fov}° · ≈ {mm} mm", + "render.frame_name": "Namn", + "render.frame_output_size": "Utdatastorlek", + "render.frame_photo": "3:2 · Foto", + "render.frame_portrait": "4:5 · Porträtt", "render.frame_presets": "Bildruteförinställningar", "render.frame_presets_pending": "Kommer med tagningsredigeraren", + "render.frame_projection": "Projektion", + "render.frame_render_shot": "Rendera den här tagningen", + "render.frame_rule_thirds": "Tredjedelsregeln", + "render.frame_safe_area": "Säkert område", + "render.frame_shot_title": "Tagning · {name}", + "render.frame_square": "1:1 · Kvadrat", + "render.frame_tall": "9:16 · Stående", + "render.frame_thirds": "Tredjedelar", + "render.frame_wide": "16:9 · Bredbild", "render.gpu_depth": "Maximalt banedjup", "render.gpu_options_help": "Prov, djup, slumpfrö och exponering sparas med den här uppsättningen. Endast den aktiva bildmetoden sparas.", "render.gpu_path_failed": "GPU-path tracern är inte tillgänglig", @@ -2360,6 +2627,7 @@ "render.heading_results": "RESULTAT", "render.heading_shots": "TAGNINGAR", "render.hide_shots_label": "Dölj tagningsremsan", + "render.hover_look": "{object} · {look} · klicka för att markera", "render.image_advanced": "Avancerade GPU-reglage", "render.image_cancelled": "Renderingen avbröts", "render.image_cancelling": "Avbryter", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "Använd den enhetliga approximationen utan rumsliga eller avancerade effekter", "render.library_usable": "Körbara projektioner", "render.library_users": "Används av", + "render.light_aim": "Rikta mot", "render.light_anchor_pending": "Att ändra ljusens koordinatsystem stöds inte ännu.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Välj CPU-strålspårning eller en uttrycklig GPU-studio för att redigera ljus. Äldre GPU-studioljus förblir skrivskyddade.", + "render.light_behaviour": "BETEENDE", + "render.light_default_fill": "Fyllnadsljus", + "render.light_default_key": "Huvudljus", + "render.light_default_number": "Ljus {number}", + "render.light_default_rim": "Kantljus", + "render.light_delete": "Ta bort", + "render.light_distance": "Avstånd", + "render.light_duplicate": "Duplicera", + "render.light_elevation": "Höjdvinkel", + "render.light_gpu_punctual": "Punktljus och spotljus är tillgängliga med CPU-strålspårning.", + "render.light_hard_shadow": "Det här ljuset har hårda skuggor; strålarens mjukhet är inte tillgänglig.", + "render.light_intensity": "Intensitet", + "render.light_missing": "Det här ljuset finns inte längre. Markera ett ljus i webbläsaren.", + "render.light_place_highlight": "Placera efter högdager", + "render.light_place_pending": "Placering efter högdager kräver val av ljus i vyfönstret och stöds inte ännu.", + "render.light_position": "POSITION", + "render.light_preview_help": "Livevyn saknar skuggor; ytljus använder en approximation med ett prov i mitten. Rendering använder den fullständiga ljusmodellen.", + "render.light_product_centre": "Produktens centrum", + "render.light_size": "Storlek", + "render.light_softness": "Mjukhet", + "render.light_temperature": "Färgtemperatur", + "render.light_temperature_help": "Ungefärlig svartkroppston; sparas som linjär RGB.", + "render.light_type": "Typ", + "render.light_type_area": "Ytljus", + "render.light_type_point": "Punktljus", + "render.light_type_spot": "Spotljus", + "render.light_type_sun": "Sol", + "render.light_view_only": "Solo och avstängning påverkar den här vyn och dess renderingar; de sparas aldrig i konstruktionen.", + "render.light_world_origin": "Världens origo", + "render.lighting_aim_point": "Siktpunkt", + "render.lighting_apply": "Tillämpa förinställning", + "render.lighting_area_samples": "Prov för ytljus", + "render.lighting_budget": "Budget", + "render.lighting_budget_help": "Antalen är skrivskyddade. Lägg till, duplicera eller ta bort ljus för att ändra dem; metoden bestämmer gränserna.", + "render.lighting_colour": "Färg", + "render.lighting_consent": "Ersätt de riktade ljusen med den här förinställningen", + "render.lighting_custom": "Egna ljus", + "render.lighting_custom_help": "Om du väljer Egna ljus görs förinställningen om till redigerbara ljus, med en rad för vart och ett i trädet.", + "render.lighting_direction": "Riktning X Y Z", + "render.lighting_direction_help": "Eller skriv en riktning mot ljuset (X Y Z).", + "render.lighting_distance_unit": " × storlek", + "render.lighting_edge_u": "Sidvektor U", + "render.lighting_edge_v": "Sidvektor V", + "render.lighting_falloff": "Dämpning", + "render.lighting_falloff_help": "Intensiteten anges vid referensavståndet.", + "render.lighting_height": "Höjd V", + "render.lighting_minimum": "Minimum", + "render.lighting_mute": "Stäng av det här ljuset", + "render.lighting_point": "Punkt", + "render.lighting_preset": "Studioförinställning", + "render.lighting_preset_cpu": "Riktade studioförinställningar kräver CPU-strålspårning. GPU-belysning använder uttryckliga sol- och ytljus.", + "render.lighting_preset_row": "Förinställning", + "render.lighting_radiance": "Radians", + "render.lighting_reference": "Referens", + "render.lighting_review_help": "Ersätter listan med riktade ljus. Behåller punkt- och ytljus, miljö, skuggor, kamera och alla andra tagningsinställningar.", + "render.lighting_review_title": "Granska belysningsförinställning", + "render.lighting_samples_cpu": "Prov för ytljus gäller CPU-strålspårning.", + "render.lighting_samples_need_area": "Lägg till ett CPU-ytljus för att ställa in prov för ytljus.", + "render.lighting_samples_reason": "Prov för ytljus måste vara 1, 4, 16 eller 64.", + "render.lighting_shadow_cpu": "Skuggbrytaren kräver CPU-strålspårning. GPU-vägspårning kastar alltid skuggor.", + "render.lighting_shadows": "Kasta skuggor", + "render.lighting_shadows_section": "Skuggor och sampling", + "render.lighting_shape": "Form", + "render.lighting_solo": "Solo för det här ljuset", + "render.lighting_source": "Källa", + "render.lighting_source_section": "Ljuskälla", + "render.lighting_spot_preserved": "Det här punktljuset behåller sin sparade spotljuskon tills dess typ ändras.", + "render.lighting_sun_distance": "Solens riktning är oberoende av avstånd och siktpunkt.", + "render.lighting_unsupported": "stöds inte", + "render.lighting_used": "{used} av {limit}", + "render.lighting_width": "Bredd U", + "render.lighting_world_position": "Värld X Y Z", "render.lights_fixed_to": "Ljus fästa vid", "render.linear_help": "Behåller en 32-bitars flyttals-RGBA-master före brusreducering, exponering, tonmappning, filter och bakgrund. Linjär sRGB/D65; täckningsförmultiplicerad. Extra minne medges uttryckligen. Ingen komprimering eller extra pass.", "render.linear_retain": "Behåll rå linjär EXR", "render.linear_unavailable": "Den valda färdiga bildrutan har ingen bevarad EXR. Aktivera bevarande före rendering; en äldre PNG kan inte återskapa rå strålningstäthet.", "render.look_add": "Lägg till färgstil", + "render.look_applied": "{name} har tillämpats", "render.look_bleach": "Bleach bypass-inspirerad", "render.look_cool": "Kall", "render.look_cyanotype": "Cyanotypi-inspirerad", "render.look_faded": "Urblekt kopia", + "render.look_material": "Material", "render.look_mono": "Monokrom", + "render.look_pasted": "Stilen klistrades in", "render.look_sepia": "Sepia", "render.look_strength": "Stilstyrka", "render.look_teal_orange": "Blågrön / orange", @@ -2592,9 +2938,11 @@ "render.output_advanced": "Avancerat", "render.output_backend_ready": "Redo", "render.output_check": "Kontrollera", + "render.output_check_failed": "Kontrollera · fel", "render.output_check_help": "Kör Kontrollera för att granska begäran utan att starta en rendering.", "render.output_check_summary": "Kontrollera · Anmärkningar: {count}", "render.output_copy": "Kopiera", + "render.output_copy_report": "Kopiera rapport", "render.output_destination": "Mål", "render.output_destination_help": "Välj målfil när den färdiga bilden exporteras.", "render.output_dimensions": "Storlek", @@ -2604,6 +2952,7 @@ "render.output_fit": "Anpassa", "render.output_format": "Format", "render.output_format_status": "PNG är den exakta färdiga visningsbilden. Valfri GPU-EXR bevarar rå scenlinjär FLOAT32-strålningstäthet före visningsfilter. Export ersätter aldrig förhandsvisningscachen eller ett annat jobb.", + "render.output_grading": "Färgkorrigering", "render.output_image": "Bild", "render.output_largest": "max {width} × {height}", "render.output_lock_ratio": "Lås bildförhållandet", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "väntar på kvalitetskontraktet", "render.output_ratio_refused": "Det länkade måttet överskrider den här metodens storleksgräns. Den tidigare storleken behålls; lås upp förhållandet eller välj ett mindre värde.", "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_ok": "Storleken är OK · {width} × {height} px", "render.output_swap_orientation": "Byt bredd och höjd", "render.output_tools_inspector": "Redigera storlek och format i inspektören Utdata.", @@ -2629,6 +2980,7 @@ "render.page_output": "Utdata", "render.page_scene": "Scen", "render.paint_label": "Måla", + "render.painting_with": "Målar med {name}", "render.passive_stage": "Golv och passivt dekor", "render.passive_stage_help": "Ändliga passiva paneler hör endast till renderade bilder. De lägger inte till några CAD-kroppar eller någon massa. Placeringen är fast efter tillämpning; placeringar som skär produktens begränsningsvolym avvisas. CPU-vägen ger ingen diffus indirekt belysning.", "render.perspective": "Perspektivkamera", @@ -2650,6 +3002,7 @@ "render.pick_help": "Välj Kropp, Yta eller Komponent. Klicka i tomt utrymme för att rensa; Shift eller Ctrl lägger till eller växlar. Framhävningar visas endast i den direkta produktvyn.", "render.pick_label": "Välj", "render.pick_up_label": "Plocka upp", + "render.pick_up_prompt": "Klicka på ett objekt för att plocka upp dess stil", "render.pick_waiting": "Val i produktvyn stöds inte ännu.", "render.post_contrast": "Kontrast", "render.post_gain_b": "Blåförstärkning", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "Radiell distorsion", "render.radial_fit": "Anpassa produkt", "render.radial_save": "Spara tagning", + "render.recent_looks": "Senaste stilar", "render.regrade_action": "Färgkorrigera råmastern på nytt (ingen brusreducering)", "render.regrade_help": "Skapar en separat PNG från den valda färdiga råmastern med de här presentationskontrollerna. Spårar inga strålar, ändrar inte den fångade storleken, kameran eller ljusen och skriver inte över EXR-filen. Brusreducering tillämpas inte på nytt: råmastrar behåller inte dess guidedata. Derivatet kan fortfarande exporteras via samma jobbkontroller.", "render.regrade_label": "Ny färgkorrigering", "render.reload_settings": "Läs in sparade inställningar igen", + "render.remove_override": "Ta bort åsidosättning", + "render.replace_all": "Ersätt alla", "render.replace_ground": "Bekräfta ersättning av befintligt dekor", "render.replace_label": "Ersätt", "render.replace_saved_surface": "Ersätt en befintlig sparad bindning för det här målet", + "render.replace_with": "Ersätt med", "render.restore_camera": "Återställ sparad kamera", "render.result_actual": "100%", "render.result_actual_help": "En bildpixel per skärmpixel.", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "Ta bort sparad bindning", "render.saved_surfaces": "Sparade ytbindningar", "render.saved_surfaces_help": "Fästa inbyggda kartor följer den valda förekomsten i lokala millimeter. Ett ersatt eller saknat mål är ett fel, aldrig en automatisk omtilldelning.", + "render.scene_all_shots": "Alla tagningar ({count})", "render.scene_apply": "Tillämpa granskad studio", + "render.scene_batch_reason": "Alla tagningar kräver renderingskön för sparade tagningar, som inte är tillgänglig ännu.", + "render.scene_check_now": "Kontrollera nu", + "render.scene_check_passed": "Kontrollen godkänd · {notes} anmärkningar", + "render.scene_check_stale": "Kontrollen är inaktuell · kontrollera igen", + "render.scene_check_unchecked": "Inte kontrollerad än · 0 anmärkningar", "render.scene_consent": "Bekräfta ersättning av belysning, miljö, exponering och dekor", + "render.scene_light_summary": "{environment} · {lights} ljus", + "render.scene_look_summary": "{looks} stilar · {overrides} åsidosättningar", + "render.scene_open_check": "Öppna Kontrollera", "render.scene_preserved_fields": "Kamera, sampling, bildmått, optiska bindningar, CAD-geometri och tekniska material bevaras.", "render.scene_preserves_design": "Komponera belysning och ett passivt dekor för det synliga motivet. Geometri, tekniska material och kameran förblir separata.", + "render.scene_ready": "Redo att rendera", + "render.scene_reminder": "Scen bevarar konstruktionen. Geometri, tekniska egenskaper och CAD ändras aldrig från Rendera.", + "render.scene_reminders": "Påminnelser", + "render.scene_renderer_ready": "{renderer} redo", "render.scene_replaces": "Ersätter", "render.scene_review": "Lös och granska", "render.scene_review_help": "Granska hela den inbyggda kompositionen innan du tillämpar. Obundna avancerade SCENE-specifikationer ersätts inte.", "render.scene_scope_details": "Kompositionens omfattning", + "render.scene_stages": "Steg", "render.search_appearances": "Sök bland {count} utseenden", "render.setup_legacy_environment": "Den här äldre uppsättningen använder miljörotation eller intensitet i äldre format. Värdena behålls; konvertera dem uttryckligen innan du sparar ett fullständigt bildjobb.", "render.setup_name": "Inställningsnamn", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "RENDERINGSKAMERA", "render.shell_tab_lighting": "BELYSNING", "render.shell_tab_scene": "RENDERINGSSCEN", + "render.shot_aperture": "Bländare", + "render.shot_distance": "Avstånd", + "render.shot_dof": "Skärpedjup", "render.shot_duplicate": "Duplicera sparad tagning", + "render.shot_edit_output": "Redigera i Utdata", + "render.shot_exposure_reason": "Exponering kräver CPU-strålspårning eller GPU-vägspårning.", + "render.shot_exposure_section": "Exponering", + "render.shot_focus_distance": "Fokusavstånd", + "render.shot_fov_reason": "Synfält kräver perspektivprojektion.", + "render.shot_frame_subject": "Rama in motivet", + "render.shot_invalid_camera": "Kameravärdena måste vara ändliga och inom intervallet.", + "render.shot_lens": "Objektiv", + "render.shot_lens_enable_reason": "Aktivera skärpedjup för att redigera bländare och fokusavstånd.", + "render.shot_lens_gpu_reason": "Skärpedjup kräver GPU-vägspårning.", + "render.shot_lens_perspective_reason": "Skärpedjup kräver perspektivprojektion.", + "render.shot_look_selection": "Titta på markeringen", "render.shot_manager": "Sparade tagningar", "render.shot_new_name": "Namn på ny tagning", "render.shot_remove": "Ta bort sparad tagning", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "Ta bort sparad tagning?", "render.shot_rename": "Byt namn på sparad tagning", "render.shot_saved_only": "Spara det aktuella utkastet först. Duplicera och Byt namn bevarar exakt de sparade alternativen och resursreferenserna.", + "render.shot_selection_reason": "Markera en kropp eller yta att rikta mot.", + "render.shot_size": "Storlek", + "render.shot_target": "Mål", "render.shots_camera_changed": "Kameran har ändrats sedan tagningen sparades", "render.shots_name_help": "Ange ett unikt namn för den aktuella tagningen.", "render.shots_no_result": "Ingen rendering än", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "stöds inte ännu: val av produkt och provtagning av utseende", "render.wait_replace": "stöds inte ännu: ersättning av alla användningar av ett utseende", "render.wait_solo": "stöds inte ännu: ljussynlighet och solokontroller", + "render.wear_applies_to": "Gäller för", + "render.wear_layer_count": "{count} skikt", + "render.wear_layer_one": "{count} skikt", + "render.wear_layers": "Skikt", + "render.wear_no_layers": "Inga skikt", + "render.wear_selected_layer": "Valt skikt", + "render.wear_title": "Slitage", "sample.bracket.about": "Basfläns med kantflänsar och fals enligt 2 mm-regel", "sample.bracket.name": "Plåtfäste", "sample.enclosure.about": "Urholkad låda med läpp, skruvnav, stöd och snäppfäste", @@ -3030,6 +3426,7 @@ "select an axis": "markera en axel", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "markera först en axel, en rak eller cirkulär kant, eller en cylinder-, kon- eller torusyta", "select bodies first": "markera kroppar först", + "select components first": "markera komponenter först", "select corner points, or the two lines of one corner": "markera hörnpunkter, eller de två linjerna i ett hörn", "select one corner point, or the two lines of one corner": "markera en hörnpunkt, eller de två linjerna i ett hörn", "select sheet edges first": "markera plåtkanter först", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "Dubbelklicka på en komponent för att aktivera den eller för att redigera en länkad komponent på plats", "settings.frame_rate_details_note": "Lägger till en graf över de senaste bildrutornas tider med trianglarna ritade ovanpå, ett histogram över bildrutetider och vad GPU:n ritade. Klicka på bildfrekvensen i vyn för att visa eller dölja dem.", "settings.frame_rate_note": "Visar bildrutor per sekund i vyns nedre högra hörn.", + "settings.insert_lists_open_designs": "Infoga konstruktion listar först konstruktionerna som är öppna i andra flikar", "settings.measure_axis_distances": "Visa X/Y/Z-avstånd", "settings.measure_axis_distances_help": "Visa världsaxelns komponentmått bredvid det totala avståndet medan Mät är öppet.", "settings.msaa_applies_on_restart": "Kantutjämning tillämpas nästa gång SolidCraft startar", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "Lossa", "sketch is missing": "skissen saknas", "sketch.degrees_of_freedom": "{count} frihetsgrader", + "sketch.dimension_added_driven": "Skissen bestämmer redan det måttet, så det lades till som ett styrt mått (referensmått)", + "sketch.dimension_applied": "Måttet tillämpades", + "sketch.dimension_applied_defined": "Måttet tillämpades; parametern `{name}` definierades", + "sketch.dimension_applied_renamed": "Måttet tillämpades; parametern fick namnet `{name}`", "sketch.dof_left": "{count} frihetsgrader kvar", "sketch.dof_left_one": "{count} frihetsgrad kvar", "sketch.draw_curves": "Rita som {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "Använd teckensnittsfilen {path}", "solid": "solid", "status.editing": "Redigerar {name}", + "status.navigation": "Musmappning: {mapping}", + "status.navigation_tip": "Hur musen flyttar vyn. Klicka för att välja en annan mappning", "status.under_constrained": "Underbestämd ({count} frihetsgrader)", "status.under_constrained_one": "Underbestämd ({count} frihetsgrad)", "status.units_tip": "Konstruktionens enheter. Klicka för att ändra dem", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "Behåll deras funktioner och kapslade mappar i historiken", "timeline.delete_folders_with_features_tip": "Ta bort mapparna med alla funktioner i dem och deras kapslade mappar", "timeline.delete_many": "Ta bort {count} markerade funktioner", + "timeline.edited_rolled_back": "{name} ändrades, men den ligger efter historikmarkören och byggs därför inte: flytta markören förbi den för att se resultatet", "timeline.feature_error": "Fel: {message}", "timeline.feature_hint": "Dubbelklicka för att redigera · dra för att ordna om · högerklicka för mer", + "timeline.feature_rolled_back": "Tillbakarullad: efter historikmarkören, så den byggs inte", "timeline.feature_warning": "Varning: {message}", "timeline.group": "Ny funktionsmapp", "timeline.move_many": "Flytta tidslinjeobjekt", "timeline.move_to_group": "Flytta funktioner till mapp", "timeline.rename_group": "Byt namn på funktionsmapp", "timeline.reorder_many": "Ändra funktionsordning", + "timeline.suppress_features": "Undertryck funktioner", "timeline.suppress_folders_tip": "Undertryck eller häv undertryckning av alla funktioner i mapparna och deras kapslade mappar", "timeline.ungroup": "Ta bort funktionsmappar", + "timeline.unsuppress_features": "Häv undertryckning av funktioner", "to the face": "till ytan", "to the next face": "till nästa yta", "toolbar.customize_note": "Fliken {tab}. Bocka för ett kommando för att ge det en verktygsfältsknapp. Du kan också dra en rad i en panels rullgardinsmeny till verktygsfältet, dra en knapp från verktygsfältet för att ta bort den, eller använda nålen på en rad i rullgardinsmenyn.", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "Att kalibrera din 3D-mus rättar förväxlade eller omvända axlar och ställer in hastighet och känsla.", "ui.3d_mouse_offer_never": "Visa inte igen", "ui.3d_mouse_offer_swap": "Byt till Hidraw (direkt drivrutin)", + "ui.3d_mouse_orbit": "Orbit", + "ui.3d_mouse_orbit_classic": "Klassisk", + "ui.3d_mouse_orbit_tip": "Spårad: 3D-musen vrider vyn runt den del av modellen som är mitt i vyn när vridningen börjar, markerad med en svag kula. Klassisk: runt vyns centrum", + "ui.3d_mouse_orbit_traced": "Spårad", "ui.3d_mouse_reading": "Avläsning", "ui.3d_mouse_reset_axes": "Återställ axeltilldelning", "ui.3d_mouse_reset_view": "Återställ vyn", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Vrid knappen moturs, håll den och släpp sedan.", "ui.3d_mouse_switch_cap": "Kalibrera som återgång till centrum", "ui.3d_mouse_switch_trackball": "Kalibrera som styrkula", + "ui.3d_mouse_use_direct": "Använd direkt drivrutin", + "ui.3d_mouse_use_direct_tip": "Läs 3D-musen direkt (hidraw) i stället för via spacenavd.", "ui.3d_mouse_view_axis": "Vyaxel", "ui.3d_mouse_view_off": "Av", "ui.3d_mouse_view_off_tip": "3D-musdrivrutinens profil stänger av den här axeln, så dess rörelse flyttar inte vyn. Om du väljer en annan axels rörelse här byter de två plats, och den rörelsen slutar flytta vyn.", @@ -3562,6 +3976,8 @@ "ui.cancel": "Avbryt", "ui.canvases": "Dukar", "ui.center": "Centrum", + "ui.center_arc_follows_cursor": "Båge med centrumpunkt följer markörens riktning", + "ui.center_arc_follows_cursor_tip": "En båge med centrumpunkt går medurs eller moturs beroende på hur markören rör sig runt dess centrum. Av: alltid moturs från start.", "ui.centerline": "Centrumlinje", "ui.chain": "Kedja", "ui.chamfer": "Fasning", @@ -3570,6 +3986,7 @@ "ui.circle": "Cirkel", "ui.circular_pattern": "Cirkulärt mönster", "ui.clear": "Rensa", + "ui.clear_print_face": "Rensa utskriftsyta", "ui.clear_recent": "Rensa senaste", "ui.clear_the_selection": "Rensa markeringen", "ui.click_selects_whole_bodies": "Klick markerar hela kroppar", @@ -3604,6 +4021,15 @@ "ui.constant": "Konstant", "ui.constraints_conflict": "Villkor i konflikt", "ui.construction": "Konstruktion", + "ui.construction.angle_hint": "Konstruktionslinje: klicka på en punkt och skriv sedan en vinkel eller välj en riktning", + "ui.construction.bisector_hint": "Vinkelbisektris: välj två skärande raka linjer och sedan önskad lösning", + "ui.construction.common_hint": "Gemensam tangent: välj två cirklar/bågar och sedan önskad lösning", + "ui.construction.contact_hint": "Tangent: välj en cirkel/båge eller öppen spline/konisk kurva och sedan beröringspunkten (friformskurvor: ändpunkter)", + "ui.construction.external_hint": "Tangent: välj en cirkel eller båge, en yttre punkt och sedan önskad lösning", + "ui.construction.free_hint": "Konstruktionslinje: klicka på två punkter; högerklicka för att börja om", + "ui.construction.point_hint": "Konstruktionslinje: klicka på punkten som linjen ska gå genom", + "ui.construction.reference_hint": "Konstruktionslinje: välj en rak linje och sedan en punkt; högerklicka för att byta referens", + "ui.construction.ribbon_label": "Hjälplinjer", "ui.context_menu": "Snabbmeny", "ui.continuity_g0": "G0-veck", "ui.continuity_g1": "G1-tangent", @@ -3695,6 +4121,14 @@ "ui.end_scale": "Slutskala", "ui.entities": "Objekt", "ui.equal": "Lika", + "ui.equation_end": "Slut", + "ui.equation_explicit": "Explicit", + "ui.equation_form": "Form", + "ui.equation_note": "Variabeln är ett vanligt tal: sin(t) tolkar den i radianer. Ett resultat utan enhet anges i konstruktionens enhet.", + "ui.equation_parametric": "Parametrisk", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalent radie", "ui.expand": "Expandera", "ui.exportDxf": "Exportera DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "KONSTRUKTION", "ui.heading_draft": "SLÄPPVINKEL", "ui.heading_edit": "REDIGERA", + "ui.heading_equation_curve": "EKVATIONSKURVA", "ui.heading_extrude": "EXTRUDERA", "ui.heading_fillet": "AVRUNDNING", "ui.heading_finish_editing": "SLUTFÖR REDIGERING", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "Importera…", "ui.infinite": "Oändlig", "ui.insert": "Infoga", + "ui.insert_design.browse": "Bläddra…", + "ui.insert_design.open_designs": "Öppna konstruktioner", + "ui.insert_design.saved_file": "{name} har osparade ändringar, så dess sparade fil infogades. Spara den och kör sedan Uppdatera länkar för att hämta in ändringarna.", + "ui.insert_design.unsaved": "Osparade ändringar", "ui.inspect": "Granska", "ui.intersect": "Skärning", "ui.isolate": "Isolera", @@ -3994,12 +4433,29 @@ "ui.polygon": "Polygon", "ui.preferences": "Inställningar", "ui.press_a_key": "tryck på en tangent…", + "ui.print_face_cleared": "{body}: utskriftsyta rensad", + "ui.print_face_input": "Utskriftsyta", + "ui.print_face_one_body": "En utskriftsyta hör till en kropp: skicka den kroppen ensam för att ändra den", + "ui.print_face_pick": "Välj en plan yta på en solid kropp för att göra den till utskriftsytan", + "ui.print_face_set": "{body}: utskriftsyta angiven; Skicka till slicer lägger kroppen på den", "ui.profile": "Profil", "ui.profile_plane": "Profilplan", "ui.profiles": "Profiler", "ui.project": "Projicera", "ui.project_face_edges_sketch": "Projicera ytkanter i nya skisser", "ui.properties": "Egenskaper", + "ui.properties.copy_matrix": "Kopiera matris", + "ui.properties.first_moments": "Första ordningens moment", + "ui.properties.inertia": "Tröghetsmoment", + "ui.properties.inertia_note": "Kring masscentrum, i världsaxlar (g·{unit}²)", + "ui.properties.inertia_unavailable": "Inte tillgängligt: en kropps mesh är inte sluten.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Mäter…", + "ui.properties.measuring_failed": "Mätningen avbröts innan den var klar.", + "ui.properties.principal_axis": "Huvudaxel {number}", + "ui.properties.principal_moments": "Huvudtröghetsmoment", "ui.pull_direction": "Dragriktning", "ui.quality": "Kvalitet", "ui.quantity": "Antal", @@ -4045,6 +4501,7 @@ "ui.revolve": "Rotation", "ui.ribbon_exit": "Avsluta", "ui.ribbon_labels": "Visa text under menyfliksikoner", + "ui.ribbon_menus_skip_pinned": "Utelämna fästa kommandon från rullgardinsmenyerna", "ui.ribbon_pin_footer": "Fästa kommandon visas i menyfliken. Håll muspekaren över en rad för att fästa eller lossa den.", "ui.ribbon_workspace": "Arbetsyta", "ui.right": "Höger", @@ -4062,6 +4519,7 @@ "ui.select": "Markera", "ui.selectOther": "Markera annan…", "ui.selectTangentChain": "Markera tangentkedja", + "ui.select_all": "Markera alla", "ui.selection": "Markering", "ui.selection_modifiers": "Markeringsmodifierare", "ui.selection_shift_add_only": "Shift+klick lägger till utan att ta bort", @@ -4098,7 +4556,11 @@ "ui.sketch": "Skiss", "ui.sketch_dimension": "Skissmått", "ui.sketch_grid": "Skissrutnät", + "ui.sketch_intersect_geometry": "Geometri", + "ui.sketch_intersect_projection_link": "Projektionslänk", + "ui.sketch_intersect_selection_filter": "Markeringsfilter", "ui.sketch_modifiers": "Skissmodifierare", + "ui.sketch_project_geometry": "Geometri", "ui.sketch_scale": "Skissskala", "ui.sketches": "Skisser", "ui.slice": "Skiva", @@ -4149,6 +4611,7 @@ "ui.tab_manage": "Hantera", "ui.tab_sheet_metal": "Plåt", "ui.tab_view": "Vy", + "ui.tab_weldments": "Svetsning", "ui.tangent": "Tangent", "ui.taper_angle": "Avsmalningsvinkel", "ui.tapped": "Gängad", @@ -4164,6 +4627,7 @@ "ui.tip_angle": "Spetsvinkel", "ui.to_object": "Till objekt", "ui.tool_direction": "Verktygsriktning", + "ui.toolbar_tabs_hidden": "Alla verktygsfältsflikar är dolda. Klicka på + eller högerklicka här för att visa en.", "ui.top": "Ovan", "ui.top_diameter": "Toppdiameter", "ui.top_radius": "Toppradie", @@ -4201,6 +4665,8 @@ "ui.view_centre": "Vycentrum", "ui.viewport_status_clear": "Stäng av {name}", "ui.viewport_status_clear_hint": "Stäng av {name} · Esc", + "ui.viewport_status_edges": "{count} kanter", + "ui.viewport_status_from": "från {direction}", "ui.viewport_status_key": "Visa teckenförklaring för {name}", "ui.viewport_status_minimum_radius": "Minsta radie", "ui.viewport_status_pairs": "{count} par", @@ -4210,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Skuggad med kanter", "ui.visual_style": "Visuell stil", "ui.visual_wireframe": "Trådram", + "ui.wheel_zoom_speed": "Zoomhastighet för mushjulet", "ui.width": "Bredd", "ui.window_system": "Fönstersystem", "ui.window_system_auto": "Automatiskt", @@ -4240,7 +4707,9 @@ "update.button": "Uppdatera", "update.button_version": "Uppdatera till {version}", "update.check_automatically": "Sök efter uppdateringar automatiskt", + "update.check_for_updates": "Sök efter uppdateringar", "update.check_now": "Sök nu", + "update.check_unavailable": "Sökning efter uppdateringar är inte tillgänglig för den här installationen.", "update.checking": "Söker…", "update.copy": "Kopiera", "update.dmg_opened": "Diskavbilden är öppen och verifierad: dra SolidCraft till Program och ersätt den gamla kopian, starta sedan om SolidCraft.", @@ -4254,13 +4723,18 @@ "update.installed": "SolidCraft {version} är installerad. Starta om för att använda den.", "update.installer_started": "Installationsprogrammet startar när SolidCraft stängs.", "update.later": "Påminn mig senare", + "update.managed": "Uppdatering tillgänglig: uppdatera SolidCraft i {manager}", "update.no_notes": "Den här utgåvan har inga anteckningar.", "update.package_hint": "Den här kopian kom från ett {kind}. Ladda ned det nya paketet och installera det sedan med:", "update.prefs_dev": "Detta är ett utvecklingsbygge ({version}): det söker inte efter uppdateringar.", "update.prefs_flatpak": "Flatpak uppdaterar den här kopian ({version}): kör {command} eller använd din programhanterare. SolidCraft söker inte själv.", "update.prefs_label": "Uppdateringar", + "update.prefs_managed": "Detta är SolidCraft {version}, hanterat av {manager}. En sökning frågar GitHub efter den senaste utgåvan och skickar inget annat; uppdatera SolidCraft i {manager}.", "update.prefs_note": "Detta är SolidCraft {version} ({kind}). En sökning frågar GitHub efter den senaste utgåvan och skickar inget annat.", "update.prefs_web": "Läs in sidan igen för att få den senaste webbappen. Om nätverket inte är tillgängligt eller om uppdateringen inte hinner laddas ned i tid öppnas den sparade versionen i stället; en nedladdning som pågår fortsätter till nästa omläsning.", + "update.refresh_checking": "Söker efter den senaste utgåvan…", + "update.refresh_fallback": "Kunde inte söka efter en nyare utgåva; uppdaterar till {version}, som hittades tidigare.", + "update.refresh_selected": "Uppdaterar till {version}.", "update.release_page": "Öppna utgåvesidan", "update.restart": "Starta om nu", "update.restart_later": "Senare", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index e01e4edf86..87e728d447 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Açtığınız veya kaydettiğiniz tasarımlar burada görünür.", "Determined by To Object. Choose Angle to edit the angular extent.": "Nesneye kadar seçeneğiyle belirlenir. Açısal kapsamı düzenlemek için Açı seçeneğini seçin.", "Diameter": "Çap", + "Did this assembly open the wrong way up?": "Bu montaj yanlış yönde mi açıldı?", + "Did this part open the wrong way up?": "Bu parça yanlış yönde mi açıldı?", "Dimension new shapes": "Yeni şekillere ölçü ver", "Direction": "Yön", "Discard": "At", @@ -204,6 +206,7 @@ "Edges": "Kenarlar", "Edit": "Düzenle", "Edit Profile Sketch": "Profil eskizini düzenle", + "Edit shortcuts": "Kısayolları düzenle", "Enable rule": "Kuralı etkinleştir", "Enabled": "Etkin", "End Radius": "Bitiş yarıçapı", @@ -466,6 +469,7 @@ "Radius": "Yarıçap", "Radius Fit": "Yarıçap uydurma", "Ratio": "Oran", + "Read-only list: shortcuts can't be changed here.": "Salt okunur liste: kısayollar burada değiştirilemez.", "Reattach selected face": "Seçili yüze yeniden bağla", "Recover": "Kurtar", "Recover unsaved design?": "Kaydedilmemiş tasarım kurtarılsın mı?", @@ -599,6 +603,7 @@ "Square": "Kare", "Stand it up": "Dik çevir", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Dik çevir: X etrafında çeyrek tur; geri alınabilen tek bir Taşı özelliği olarak eklenir.", + "Stand this assembly up?": "Bu montaj dik çevrilsin mi?", "Stand this part up?": "Bu parça dik çevrilsin mi?", "Start angle": "Başlangıç açısı", "Start bulge": "Başlangıç şişkinliği", @@ -649,6 +654,7 @@ "Third point": "Üçüncü nokta", "This child is shared. Deleting it removes it from every copy of its parent.": "Bu alt öğe paylaşılıyor. Silindiğinde üst öğesinin tüm kopyalarından kaldırılır.", "This child is shared. Position changes apply to every copy of its parent.": "Bu alt öğe paylaşılıyor. Konum değişiklikleri üst öğesinin tüm kopyalarına uygulanır.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Bu bileşen, birden fazla kez yerleştirilmiş bir alt montajın içinde: onu hizalamak, onu her yerleşimde taşır.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Bu yüz kalıcı bir bağlama sağlayamaz. Başka bir yüz seçin veya açıkça sabit bir düzlem kullanın.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Bu görüntü henüz GitHub'a eklenmedi. Kopyalayın veya kaydedin, ardından GitHub'a ekleyin.", "Thread depth": "Diş derinliği", @@ -769,6 +775,8 @@ "at the face": "yüzde", "bom.group_children": "Alt parçalar kendi parçalarının satırında", "browser.body_name": "Gövde {number}", + "browser.calibrate_click_a": "A Noktası için görüntüye tıklayın veya koordinatlarını yazın.", + "browser.calibrate_click_b": "B Noktası için görüntüye tıklayın veya koordinatlarını yazın.", "browser.calibrate_title": "Kalibre et: {name}", "browser.capture_many": "{count} bileşen taşındı ancak konumları yakalanmadı.", "browser.capture_note": "Konumu yakala yeni konumu korur; Konumu geri al önceki konuma döndürür.", @@ -790,6 +798,11 @@ "cam.roller": "Makara", "cam.roller_pick": "Makara (isteğe bağlı)", "cam.tip_pick": "Uç (isteğe bağlı)", + "camera.shot_pitch": "Yatıklık", + "camera.shot_roll": "Yuvarlanma", + "camera.shot_yaw": "Sapma", + "canvas.decal_place_hint": "Çıkartmayı yerleştirmek için bir yüze tıklayın", + "canvas.place_hint": "Tuvali yerleştirmek için bir düzleme veya yüze tıklayın", "choose a profile": "bir profil seçin", "choose the cam's and the follower's joints": "kamın ve izleyicinin bağlantılarını seçin", "choose the joints to couple": "eşlenecek bağlantıları seçin", @@ -797,12 +810,16 @@ "click sketch curves": "eskiz eğrilerine tıklayın", "cmd.appearance_assign": "Görünüm", "cmd.appearance_chain": "Görünüm zinciri", + "cmd.appearance_copy": "Görünümü Kopyala", + "cmd.appearance_favorite": "Favori Görünüm", "cmd.appearance_library": "Görünüm kitaplığı", "cmd.appearance_list": "Görünümleri listele", "cmd.appearance_paint": "Görünümü Boya", + "cmd.appearance_paste": "Görünümü Yapıştır", "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.body.print_face": "Baskı yüzeyi", "cmd.body_rename": "Gövdeyi yeniden adlandır", "cmd.browser_collapse_all": "Tümünü Daralt", "cmd.browser_expand_all": "Tümünü Genişlet", @@ -810,6 +827,7 @@ "cmd.browser_move": "Tarayıcı öğelerini taşı", "cmd.browser_order": "Tarayıcı öğelerini sırala", "cmd.browser_rename_group": "Grubu yeniden adlandır", + "cmd.browser_reparent": "Bileşene Taşı", "cmd.browser_ungroup": "Grubu çöz", "cmd.browser_visibility": "Göster/Gizle", "cmd.canvas_calibrate": "Tuvali kalibre et", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Parça özellikleri", "cmd.component_metadata_set": "Parça özelliklerini değiştir", "cmd.component_move_bodies": "Gövdeleri bileşene taşı", + "cmd.component_move_features": "Özellikleri bileşene taşı", "cmd.component_move_sketches": "Eskizleri bileşene taşı", + "cmd.component_paste_from_design": "Başka Bir Tasarımdan Yapıştır", "cmd.component_paste_new": "Yeni olarak yapıştır", "cmd.component_properties": "Bileşenin fiziksel özellikleri", "cmd.component_relink": "Kaynağı yeniden bağla", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Hareket izi", "cmd.motion_trace_edit": "Hareket İzini Düzenle", "cmd.motion_trace_keep": "Hareket izini koru", + "cmd.occurrence_align": "Örneği Hizala", "cmd.occurrence_copy": "Yapıştır (örnek)", "cmd.occurrence_drag": "Tekrarı Taşı", "cmd.occurrence_ground": "Sabitle", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Yüzey Bağlamını Kaldır", "cmd.render_library_targets": "Render Kitaplık Hedeflerini Bul", "cmd.render_lights_add": "Işık Ekle", + "cmd.render_lights_duplicate": "Render Işığını Çoğalt", + "cmd.render_lights_list": "Render Işıklarını Listele", + "cmd.render_lights_mute": "Işığı Sustur", + "cmd.render_lights_remove": "Render Işığını Kaldır", + "cmd.render_lights_set": "Render Işığını Ayarla", "cmd.render_lights_solo": "Işığı Solo Yap", "cmd.render_optics_bind": "Optik Bağlamı Kaydet", "cmd.render_optics_unbind": "Optik Bağlamı Kaldır", "cmd.render_output_size": "Render Çıktı Boyutunu Ayarla", "cmd.render_preflight": "Render isteğini kontrol et", + "cmd.render_preflight_copy": "Render Kontrol Raporunu Kopyala", "cmd.render_preset_apply": "Yerli Stüdyoyu Uygula", "cmd.render_preset_resolve": "Yerli Stüdyoyu İncele", "cmd.render_quality_prepare": "Sabit Örnekleme Bütçesini İncele", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Resmi render et", "cmd.render_status": "Render ilerlemesi", "cmd.select_clear": "Seçimi temizle", + "cmd.settings_profile_apply": "Ayar Profilini Uygula", + "cmd.settings_profile_delete": "Ayar Profilini Sil", + "cmd.settings_profile_export": "Ayar Profilini Dışa Aktar", + "cmd.settings_profile_import": "Ayar Profilini İçe Aktar", + "cmd.settings_profile_list": "Ayar Profilleri", + "cmd.settings_profile_rename": "Ayar Profilini Yeniden Adlandır", + "cmd.settings_profile_save": "Ayar Profilini Kaydet", "cmd.sheet_convert": "Sac metale dönüştür", "cmd.sheet_export_bend_table": "Büküm tablosunu dışa aktar", "cmd.sheet_export_dxf": "Açınımı DXF olarak dışa aktar", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Yeniden katla", "cmd.sheet_rules": "Sac metal kurallarını listele", "cmd.sheet_unfold": "Katlamayı aç", + "cmd.sketch.construction_line": "Yardımcı doğru", + "cmd.sketch.construction_line.angle": "Açılı yardımcı doğru", + "cmd.sketch.construction_line.bisector": "Açıortay", + "cmd.sketch.construction_line.horizontal": "Yatay yardımcı doğru", + "cmd.sketch.construction_line.parallel": "Paralel yardımcı doğru", + "cmd.sketch.construction_line.perpendicular": "Dik yardımcı doğru", + "cmd.sketch.construction_line.tangent_at": "Eğri noktasındaki teğet", + "cmd.sketch.construction_line.tangent_external": "Dış noktadan geçen teğet", + "cmd.sketch.construction_line.tangent_two": "Ortak teğet", + "cmd.sketch.construction_line.vertical": "Dikey yardımcı doğru", "cmd.sketch_arc3d": "3B Yay", "cmd.sketch_arc_center_point": "Merkez noktalı yay", "cmd.sketch_arc_tangent": "Teğet yay", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Çizim Boyutu", "cmd.sketch_edit": "Eskizi düzenle", "cmd.sketch_edit_text": "Metni düzenle", + "cmd.sketch_equation_curve": "Denklem Eğrisi", + "cmd.sketch_equation_curve_edit": "Denklem Eğrisini Düzenle", "cmd.sketch_export_dxf": "DXF olarak kaydet", "cmd.sketch_finish_auto_constrain": "Otomatik kısıtlamayla bitir", "cmd.sketch_fit_curves_to_section": "Eğrileri ağ kesitine uydur", @@ -1198,6 +1244,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_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ı", "cmd.view_apply": "Adlandırılmış görünümü geri yükle", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Seçime Dik", "cmd.view_previous_tab": "Önceki Sekme", "cmd.view_rename": "Adlandırılmış görünümü yeniden adlandır", + "cmd.view_render_frame": "Render Kadraj Kılavuzları", "cmd.view_right": "Sağ Görünüm", "cmd.view_save": "Yeni adlandırılmış görünüm", "cmd.view_shots_strip": "Çekim Şeridi", @@ -1229,10 +1277,14 @@ "color.copy": "Renk değerlerini kopyalamak için tıklayın", "color.edit": "Rengi düzenlemek için tıklayın", "color.gamma": "Renk değerleri 0-255 gama uzayında gösteriliyor", + "color.hex": "Hex", + "color.hex_help": "#rrggbb veya #rrggbbaa girin; altı hane opaklığı korur", + "color.hex_hint": "#rrggbb", "color.hue": "Ton", "color.linear": "Renk değerleri 0-1 doğrusal uzayda gösteriliyor", "color.saturation_value": "Doygunluk ve değer", "color.selected": "Seçili renk", + "config.column_filter_empty": "Eşleşen sütun yok", "contact.components_count": "{count} bileşen", "contact.enabled_label": "etkin", "coupling.gear_1": "Dişli 1", @@ -1350,6 +1402,17 @@ "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.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.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.", + "help.sketch.construction_line.horizontal": "Bir noktadan geçen sonsuz yatay bir yardımcı doğru oluşturun. Yatay kısıtlaması sonraki düzenlemelerde de korunur.", + "help.sketch.construction_line.parallel": "Düz bir eskiz çizgisi seçin, ardından paralel bir sonsuz yardımcı doğru yerleştirin. Başka bir referans seçmek için sağ tıklayın.", + "help.sketch.construction_line.perpendicular": "Düz bir eskiz çizgisi seçin, ardından dik bir sonsuz yardımcı doğru yerleştirin. Başka bir referans seçmek için sağ tıklayın.", + "help.sketch.construction_line.tangent_at": "Bir daire veya dairesel yay üzerindeki bir noktada ya da açık bir spline veya konik eğrinin uç noktasında teğet oluşturun. Önce eğriyi, sonra değme noktasını seçin.", + "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.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.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.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.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.", @@ -1376,6 +1439,8 @@ "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.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.", + "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", "hole_presets.delete": "Sil", "hole_presets.delete_prompt": "Bu kişisel ön ayar silinsin mi? Mevcut delikler değişmez.", @@ -1585,6 +1650,8 @@ "nav.maya.help": "Alt+sol sürükleme döndürür, Alt+orta kaydırır, Alt+sağ yakınlaştırır; orta sürükleme ayrıca kaydırır", "nav.openscad.help": "Boşlukta sol sürükleme döndürür, Shift+sol sürükleme kutu seçimi yapar, eskiz geometrisi sürükleyerek düzenlenir; sağ veya orta sürükleme kaydırır, Shift+sağ veya Shift+orta sürükleme yakınlaştırır", "nav.perspective.sketch": "Eskiz düzenleme dik görünüm kullanır. Perspektife geçmek için eskizi bitirin.", + "nav.right_drag_orbits.help": "Kapalı: sağ sürükleme kameraya dokunmaz ve sağ tuş yalnızca bağlam menüsünü açar. SolidWorks fare eşlemesini seçmek bunu kapatır.", + "nav.right_drag_orbits.label": "Sağ sürükleme döndürür", "nav.solidworks.help": "Orta sürükleme döndürür, Ctrl+orta kaydırır, Shift+orta yakınlaştırır, sağ sürükleme döndürür", "nav.tinkercad.help": "Sağ sürükleme döndürür, orta veya Shift+sağ sürükleme kaydırır", "nav.touchpad.help": "Kaydırmak için Shift tuşunu basılı tutun ve imleci hareket ettirin; Alt/Option ile döndürün. Fare tuşu gerekmez. Dikey kaydırma yakınlaştırır.", @@ -1606,6 +1673,9 @@ "navbar.custom.customise": "Gezinme çubuğunu özelleştir…", "navbar.custom.design": "Tasarım", "navbar.custom.duplicate": "Bu yuva zaten çubukta.", + "navbar.custom.edge": "Kenar", + "navbar.custom.edge_bottom": "Alt", + "navbar.custom.edge_top": "Üst", "navbar.custom.fade": "Soldur", "navbar.custom.full": "Başka bir yuva eklemek için bir yuvayı kaldırın.", "navbar.custom.hidden_design": "Tasarımda gizli", @@ -1712,6 +1782,7 @@ "parts.dimension.thickness": "Kalınlık", "parts.dimension.width": "Genişlik", "parts.family.bearing": "Derin oluklu bilyeli rulman (zarf)", + "parts.family.button_head_cap_screw": "Mercimek başlı alyan cıvata", "parts.family.dowel_pin": "Silindirik pim (paralel)", "parts.family.hex_bolt": "Altıgen başlı cıvata / vida", "parts.family.hex_nut": "Altıgen somun", @@ -1770,6 +1841,11 @@ "prefs.bottom_centre": "Alt orta", "prefs.bottom_right": "Sağ alt", "prefs.browser": "Tarayıcı", + "prefs.browser_eye_left": "Göz, daraltma okunun yanında", + "prefs.browser_eye_left_note": "Tarayıcı satırları göster/gizle gözünü, satırların sağ ucundaki bir sütun yerine eskiden olduğu gibi daraltma okunun yanında gösterir.", + "prefs.browser_reveal_on_hover": "Üzerine gelince göster", + "prefs.browser_show_note": "Görünür, tarayıcıyı ekranda tutar. Üzerine gelince göster, imleç onun yerine veya sol kenarındaki ince sekmeye gelene kadar onu gizli tutar; imleç içindeyken veya ondan açılan bir menü açıkken açık kalır.", + "prefs.browser_shown": "Görünür", "prefs.built_in": "Yerleşik", "prefs.button_left": "Sol sürükleme", "prefs.button_middle": "Orta sürükleme", @@ -1817,6 +1893,8 @@ "prefs.graphics_card_restart": "SolidCraft'ı yeniden başlattıktan sonra etkili olur. WGPU_ADAPTER_NAME bu seçimi geçersiz kılar.", "prefs.graphics_note": "Hızdan ödün verip pürüzsüzlük kazanın. Her seçenek, etkisini bir örnek üzerinde gösterir.", "prefs.graphics_presets_note": "Ön ayarlar aşağıdaki seçimleri birlikte belirler.", + "prefs.hide_constraint_glyphs_zoomed_out": "Uzaklaşıldığında kısıtlama simgelerini gizle", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Eskiz, kalabalık yaratacak kadar uzaklaştırıldığında kısıtlama simgeleri görünümden çıkar. Kapalıyken: her yakınlaştırma düzeyinde sabit ekran boyutunda kalırlar.", "prefs.history": "Geçmiş", "prefs.history_origin_bodies": "Başlangıç ve gövdeleri üstte göster", "prefs.history_origin_bodies_note": "Soldaki veya tarayıcının altındaki Geçmiş, etkin bileşenin Gövdeler klasörü ve Başlangıç ile başlar; gözleri ve menüleri tarayıcıdaki gibidir. Alttaki Geçmiş değişmez.", @@ -1840,6 +1918,8 @@ "prefs.layout_note": "Sıkışık, panelleri pencere kenarlarına yerleştirir. Tarayıcı yarı saydamlığı Yüzen düzende kullanılabilir. Asistan tarafını Asistan sayfasından değiştirin.", "prefs.left": "Sol", "prefs.left_click": "Sol tıklama", + "prefs.line_drag_draws_one": "Basıp sürüklemek tek çizgi çizer", + "prefs.line_drag_draws_one_note": "Çizgi aracında basmak, sürüklemek ve bırakmak, basılan yerden bırakılan yere tek bir çizgi çizer ve çizim orada biter. Tıklamalar yine bir zincir çizer. Kapalı: sürükleme, tıklama gibi, bırakıldığı yere bir nokta koyar.", "prefs.linked_designs": "Bağlı tasarımlar", "prefs.live_input": "Canlı girdi", "prefs.live_preview": "Canlı önizleme", @@ -1853,6 +1933,9 @@ "prefs.max_file_size_step": "STEP ve içe aktarma dosyaları", "prefs.max_file_size_web": "Web uygulaması 512 MB’a kadar STEP ve içe aktarma dosyalarını, 256 MB’a kadar tasarımları açar: tarayıcı sekmesi daha büyük dosyaları barındıramaz. Masaüstü uygulamasında bu sınırlar artırılabilir.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Büyük simgeler", + "prefs.menu_list": "Liste", + "prefs.menus": "Menüler", "prefs.minutes": "dakika", "prefs.minutes_suffix": " min", "prefs.mouse": "Fare ve trackpad", @@ -1872,14 +1955,40 @@ "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.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ı", "prefs.placement": "Yerleşim", "prefs.prefer_free_sketch_points": "Noktalar örtüştüğünde hareketli eskiz noktalarını tercih et", "prefs.preview_choices": "Seçimlerinizin önizlemesi", + "prefs.print_face_highlight": "Baskı yüzeyi vurgusu", + "prefs.print_face_highlight_tip": "Bir gövdenin baskı yüzeyinin görünümde ne kadar güçlü renklendirilip çerçeveleneceği. %0 vurgu göstermez.", + "prefs.profile_active": "Etkin profil", + "prefs.profile_applied": "Profil uygulandı.", + "prefs.profile_changed": "Profil kaydedildikten sonra ayarlar değişti. Kaydet, onları profilde saklar.", + "prefs.profile_deleted": "Profil silindi.", + "prefs.profile_export": "Dışa aktar…", + "prefs.profile_exported": "Profil dışa aktarıldı.", + "prefs.profile_imported": "Profil içe aktarıldı. Ona geçmek için Etkin profil altında seçin.", + "prefs.profile_name_hint": "Profil adı, ör. Dizüstü", + "prefs.profile_rename": "Yeniden adlandır", + "prefs.profile_renamed": "Profil yeniden adlandırıldı.", + "prefs.profile_save": "Kaydet", + "prefs.profile_saved": "Geçerli ayarlar profile kaydedildi.", + "prefs.profile_skipped": "Profil uygulandı; {n} ayar geçerli değerini korudu (günlüğe bakın).", + "prefs.profiles": "Ayar profilleri", + "prefs.profiles_note": "Kısayollar, araç çubuğu, gezinme, radyal menü ve arayüz ayarları; tek bir ad altında birlikte saklanır ve tek seferde değiştirilir.", + "prefs.project_options_dialog": "Yansıtma seçenekleri diyaloğu", + "prefs.project_options_dialog_note": "Açık: yansıtmadan önce Geometri veya Gövdeler ile Yansıtma bağını seçin. Kapalı: önceki doğrudan seçim iş akışını korur.", "prefs.quality_default": "varsayılan", "prefs.quality_fastest": "en hızlı", "prefs.quality_slowest": "en yavaş", "prefs.quality_smoother": "daha pürüzsüz", + "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ı", + "prefs.ribbon_reveal": "Arama sonuçlarını şeritte göster", + "prefs.ribbon_reveal_note": "Komutlarda ara içindeki bir sonucun üzerine gelmek, komutun şeritte nerede olduğunu gösterir: sekmesini, düğmesini veya bir panelin açılır menüsündeki satırını. Aramadan başlatılan bir komut orada yanıp söner ve bir açılır menüden çalışan bir komut panelini işaretler.", "prefs.right_click": "Sağ tıklama", "prefs.saving": "Otomatik kayıt ve kurtarma", "prefs.scale_note": "Pencerede ayrıca Ctrl + / Ctrl −. Düğmeler %100'de 28 px.", @@ -1888,21 +1997,34 @@ "prefs.search_empty": "Eşleşen ayar yok", "prefs.select": "Seçim", "prefs.select_one_item": "Bir öğe seçin", + "prefs.select_other_hidden": "Gizli geometriyi Diğerini seç ile seç", + "prefs.select_other_hidden_help": "Açıkken: imlecin yakınındaki gizli gövdeleri, eskizleri ve örtülü kenarları da dahil eder. Kapalıyken: imlecin altındaki görünür geometrinin özgün listesini kullanır.", "prefs.selecting": "Seçme", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift yalnızca ekler; Ctrl / Cmd değiştirir", "prefs.shift_add_note": "Açık: Shift ekler, Ctrl / Cmd değiştirir. Kapalı (varsayılan): ikisi de değiştirir.", "prefs.shortcuts": "Kısayollar", + "prefs.single_surface_analysis": "Tek bir yüzey analizi kullan", + "prefs.single_surface_analysis_help": "Kapalıyken yüzey gölgelendirmesi ve Süreklilik birlikte açık kalabilir. Bunu açmak, en son başlatılan veya değiştirilen analizi tutar.", + "prefs.sketch_align_points": "Çizerken diğer eskiz noktalarıyla hizala", + "prefs.sketch_align_points_note": "Çizim sırasında bir nokta, noktalı bir kılavuzla aynı anda bir eskiz noktasıyla yatay, bir diğeriyle dikey olarak hizalanır ve tıklama her iki hizalamayı da korur. Kapalı: yalnızca önceki noktayla, her seferinde tek eksende.", "prefs.sketch_conflict_colour": "Çakışan geometri çakışma renginde", "prefs.sketch_conflict_colour_note": "Bir eskizin kısıtlamaları çakışırken, başarısız olan kısıtlamanın tuttuğu eğriler ve noktalar yalnızca simgesi veya ölçüsü değil, çakışma renginde de çizilir. Kapalıyken olağan renklerini korurlar.", + "prefs.sketch_construction_lines": "Genişletilmiş yardımcı doğru araçları", "prefs.sketch_exit_view": "Bir eskiz bittiğinde", "prefs.sketch_exit_view.keep": "Geçerli görünümü koru", "prefs.sketch_exit_view.previous": "Önceki görünüme dön", "prefs.sketch_exit_view.unless_rotated": "Döndürülmediyse dön", "prefs.sketch_exit_view_note": "Eskizi Bitir, İptal veya çift Esc sonrası görünümün nereye gittiği.", + "prefs.sketch_open_look_at": "Eskiz açıldığında görünümü ona doğru döndür", + "prefs.sketch_open_look_at_note": "Kapalı: eskiz oluşturmak veya düzenlemek görünümü olduğu gibi bırakır. Bak komutu, istediğinizde görünümü eskize çevirir.", + "prefs.sketch_recent_snap": "Son merkezlere ve orta noktalara otomatik yapış", + "prefs.sketch_recent_snap_note": "Çizim sırasında imleç, son iki daire merkezi veya düz kenar orta noktasından gelen kılavuzların kesiştiği yere yapışır. Kapalıyken bu kılavuzlar yalnızca görsel kalır ve imleç hareket etmez.", "prefs.sketch_selection": "Eskiz ve seçim", "prefs.sketch_selection_heading": "Eskiz ve seçim", "prefs.sketch_selection_note": "Eskizlerin nasıl yapıştığı ve tıklamaların nasıl seçtiği.", + "prefs.sketch_state_icons": "Eskiz simgeleri durumlarının renginde", + "prefs.sketch_state_icons_note": "Geçmiş'te ve tarayıcıda bir eskizin simgesi durumunun rengini alır (eksik kısıtlanmış, tam kısıtlı veya çakışan) ve araç ipucu durumu belirtir. Kapalıyken: her eskizin simgesi aynıdır.", "prefs.sketch_tab_always": "Eskiz sekmesini hep göster", "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ı", @@ -1912,6 +2034,11 @@ "prefs.source": "Kaynak", "prefs.speed": "Hız", "prefs.stand_up_note": "Bir STEP veya IGES dosyası Z yukarı açıldığında, X etrafında çeyrek tur öner.", + "prefs.stand_up_unknown": "Bilinmeyen programlardan gelen dosyalar için", + "prefs.stand_up_unknown.ask": "Diyalogda sor", + "prefs.stand_up_unknown.note": "Not göster", + "prefs.stand_up_unknown.off": "Önerme", + "prefs.stand_up_unknown_note": "Dosyanın başlığı, Y yukarı olarak kaydettiği bilinen bir program belirtmediğinde önerinin nasıl sunulacağı. Not yalnızca dosya sırtüstü yatıyor gibi göründüğünde gösterilir; Diyalogda sor, daha önce olduğu gibi böyle her dosya için sorar.", "prefs.status_bar": "Durum çubuğu", "prefs.status_bar_note": "Pencerenin alt kenarındaki ince bir çubuk, nelerin düzenlendiğini, açık eskizin durumunu rengiyle ve tasarımın birimlerini gösterir; birimlere tıklamak onları değiştirir. Bu durumda eskizin durumu artık görünümün köşesinde gösterilmez.", "prefs.status_done": "Tamam", @@ -1937,7 +2064,14 @@ "prefs.toast_place": "Bildirimler", "prefs.toggle_item": "Öğeyi ekleyin veya yeniden çıkarın", "prefs.toolbar": "Araç çubuğu", + "prefs.toolbar_tabs": "Araç çubuğu sekmeleri", "prefs.top": "Üst", + "prefs.trackpad_zoom_modifier": "İki parmakla kaydırarak yakınlaştırma tuşu", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Dikey kaydırmayla imlecin olduğu yere yakınlaştırmak için yalnızca bu tuşu basılı tutun. Kapalı, mevcut kaydırma davranışını korur.", + "prefs.trackpad_zoom_modifier.off": "Kapalı", "prefs.translucent": "Yarı saydam", "prefs.under_browser": "Tarayıcının altında", "prefs.under_cursor": "İmlecin altında", @@ -1947,13 +2081,24 @@ "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.", "prefs.viewport": "Görünüm alanı", + "prefs.viewport_analysis_status": "Görünüm alanı analiz durumunu göster", + "prefs.viewport_analysis_status_help": "Etkin analizleri görünüm alanının üstünde gösterir, her öğeden göstergesini açar ve en yeni analizi kapatmak için Esc tuşunu kullanır. Köşe göstergelerini ve Esc'in önceki davranışını geri yüklemek için kapatın.", + "prefs.viewport_background": "Arka plan", + "prefs.viewport_background_colour": "Renk", + "prefs.viewport_background_theme": "Temanın degradesi", "prefs.wheel": "Tekerlek", "prefs.wheel_trackpad": "Tekerlek ve trackpad", "prefs.workspace": "Çalışma alanı", + "prefs.zoom_stop_at_model": "Yakınlaştırmayı modelde durdur", + "prefs.zoom_stop_at_model_tip": "3B fare ve cihaz uygulaması görünümün ortasındaki yüzeye doğru yakınlaştırır; sürükleyerek yakınlaştırma, sürüklemenin başladığı noktayı sabit tutar. Kapalı: yakınlaştırma modelin içinden geçebilir. Tekerlek ve iki parmakla sıkıştırma ayrı olarak İmlece doğru yakınlaştır ayarını kullanır", "prefs.zoom_toward_cursor": "Yakınlaştırma, imlece doğru", "properties.title": "Özellikler: {name}", "recovery.days_ago": "{count} gün önce", "recovery.detail": "{file} · {features} özellik · otomatik kaydedilen: {when}", + "recovery.discard_all": "Tümünü at", + "recovery.discard_all_detail": "Bu işlem geri alınamaz.", + "recovery.discard_all_title": "{count} otomatik kayıt atılsın mı?", + "recovery.discard_all_title_one": "1 otomatik kayıt atılsın mı?", "recovery.hours_ago": "{count} sa. önce", "recovery.just_now": "az önce", "recovery.min_ago": "{count} dk. önce", @@ -2038,6 +2183,7 @@ "render.appearance_editor.weight": "Ağırlık", "render.appearance_physics_separate": "Görsel görünüm yoğunluk, termal veri veya diğer mühendislik özelliklerini atamaz.", "render.appearances": "Görünümler", + "render.assign_help": "Seçime uygulamak için tıklayın; önizleyip bırakmak için sürükleyin veya hiçbir şey seçili değilken boyamak için tıklayın.", "render.availability_native": "Yerli kompozisyon; uygulamadan önce inceleyin", "render.availability_projection": "Desteklenen yansıtma; kapsama ve atlamalara bakın", "render.availability_reference": "Referans verisi; otomatik atama değildir", @@ -2076,6 +2222,7 @@ "render.bloom_threshold": "Doğrusal vurgu eşiği", "render.browse_capabilities": "Tam yetenek kataloğunu incele", "render.browse_environments": "Orijinal HDR ortamlarına göz at", + "render.browse_looks": "Göz at…", "render.browse_native_scenes": "Yerli stüdyolara göz at", "render.browse_physical": "Fiziksel referansları incele", "render.browse_quality": "Kalite özelliklerini incele", @@ -2099,6 +2246,82 @@ "render.check_opaque": "Saydamlık: yalnızca opak yüzeyler; arka plan alfası desteklenir.", "render.check_sorted_alpha": "Saydamlık: sıralı alfa yaklaşımı.", "render.check_transmission": "Saydamlık: fiziksel geçirgenlik.", + "render.children.absolute_floor": "Mutlak taban", + "render.children.adaptive": "Adaptif", + "render.children.add_look_reason": "Etkin durumunu ve şiddetini düzenlemek için bir görünüm ekleyin.", + "render.children.all_shots": "Tüm çekimler", + "render.children.bloom": "Bloom", + "render.children.check": "Kontrol", + "render.children.check_now": "Şimdi kontrol et", + "render.children.chromatic": "Kromatik", + "render.children.compact": "Sıkışık", + "render.children.contrast": "Kontrast", + "render.children.copy_image": "Kopyala", + "render.children.copy_job": "İş JSON'unu kopyala", + "render.children.copy_request": "İstek JSON'unu kopyala", + "render.children.coverage_error": "Kapsama hatası", + "render.children.cpu": "CPU ışın izleme", + "render.children.denoise": "Bitmiş görüntüdeki gürültüyü azalt", + "render.children.depth": "Yol derinliği", + "render.children.distortion": "Bozulma", + "render.children.edge_samples": "Kenar örnekleri", + "render.children.effects_banner": "Bu efektler GPU yol izleme gerektirir. Bunu açmak yöntemi asla değiştirmez.", + "render.children.enable_adaptive": "Adaptif sınırları düzenlemek için bağımsız pilotları etkinleştirin.", + "render.children.enable_bloom": "Şiddetini, eşiğini ve yarıçapını düzenlemek için bloom'u etkinleştirin.", + "render.children.finishing": "Sonlandırma", + "render.children.flare": "Flare", + "render.children.fresnel": "Fresnel tabakaları", + "render.children.ghosts": "Hayalet halkalar", + "render.children.glass": "Cam taşınımı", + "render.children.go_to": "{editor} bölümüne git", + "render.children.grain": "Gren", + "render.children.halo": "Hale", + "render.children.hdr": "HDR", + "render.children.keep_master": "Ham doğrusal ana görüntüyü sakla", + "render.children.last_result": "Son: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Mercek efektleri", + "render.children.look_summary": "{tone} · {count} görünüm", + "render.children.looks": "Yaratıcı görünümler", + "render.children.memory": "Bellek", + "render.children.method": "Yöntem", + "render.children.minimum": "En az", + "render.children.needs_cpu": "CPU ışın izleme gerektirir. Bunu açmak yöntemi asla değiştirmez.", + "render.children.nested": "Sertifikalı iç içe dielektrikler", + "render.children.not_checked": "Kontrol edilmedi", + "render.children.path": "Yol izleme", + "render.children.pilots": "Pilot örnekler", + "render.children.preview": "Önizleme yükü", + "render.children.progressive_reason": "Örnek sayısını ve tohumunu düzenlemek için Artımlı'yı seçin.", + "render.children.radius": "Yarıçap", + "render.children.reflect_depth": "Yansıma derinliği", + "render.children.reflect_samples": "Yansıma örnekleri", + "render.children.regrade": "Yeniden derecelendir", + "render.children.relative_error": "Göreceli hata", + "render.children.render": "Render", + "render.children.render_shot": "Çekimi render et", + "render.children.renderer": "İşleyici", + "render.children.renderers": "İşleyiciler", + "render.children.request": "Bu istek", + "render.children.reset_finishing": "Sonlandırmayı sıfırla", + "render.children.review_quality": "Bir kalite özelliğini incele…", + "render.children.rgb_gain": "RGB kazancı", + "render.children.samples": "Örnekler", + "render.children.sampling": "Örnekleme", + "render.children.saturation": "Doygunluk", + "render.children.scene_summary": "{bodies} gövde · {triangles} üçgen", + "render.children.seed": "Tohum", + "render.children.sharpening": "Keskinleştirme", + "render.children.single_image": "Tek görüntü", + "render.children.soft_focus": "Yumuşak odak", + "render.children.streak": "Çizgi", + "render.children.strength": "Şiddet", + "render.children.studio": "Stüdyo", + "render.children.threshold": "Eşik", + "render.children.tone": "Ton", + "render.children.under_hood": "Perde arkası", + "render.children.vignette": "Vinyet", + "render.children.workstation": "İş istasyonu", + "render.choose_look": "Önce Görünümler'de bir görünüm seçin", "render.chromatic_offset": "Kromatik kenar kayması", "render.clear_stage": "Kaydedilmiş sahneyi kaldır", "render.copy_request": "JSON kopyala", @@ -2350,8 +2573,33 @@ "render.flare_strength": "Vurgu flare şiddeti", "render.flare_threshold": "Sahne-doğrusal flare eşiği", "render.fold_browser": "Tarayıcıyı daralt", + "render.frame_aspect": "En-boy oranı", + "render.frame_centre_mark": "Merkez işareti", + "render.frame_copy_limit": "Kullanılabilir bir kopya çekim adı yok", + "render.frame_copy_name": "{name} kopya {number}", + "render.frame_custom": "Özel", + "render.frame_distance_unit": " × boyut", + "render.frame_duplicate": "Çoğalt", + "render.frame_exposure": "Pozlama", + "render.frame_fov": "Görüş alanı", + "render.frame_guides": "Kılavuzlar", + "render.frame_invalid_aspect": "Geçersiz en-boy oranı ön ayarı", + "render.frame_lens": "FOV {fov}° · ≈ {mm} mm", + "render.frame_name": "Ad", + "render.frame_output_size": "Çıktı boyutu", + "render.frame_photo": "3:2 · Fotoğraf", + "render.frame_portrait": "4:5 · Portre", "render.frame_presets": "Kadraj ön ayarları", "render.frame_presets_pending": "Çekim düzenleyicisiyle gelecek", + "render.frame_projection": "Projeksiyon", + "render.frame_render_shot": "Bu çekimi render et", + "render.frame_rule_thirds": "Üçler kuralı", + "render.frame_safe_area": "Güvenli alan", + "render.frame_shot_title": "Çekim · {name}", + "render.frame_square": "1:1 · Kare", + "render.frame_tall": "9:16 · Dikey", + "render.frame_thirds": "Üçte birler", + "render.frame_wide": "16:9 · Geniş ekran", "render.gpu_depth": "En fazla yol derinliği", "render.gpu_options_help": "Örnekler, derinlik, tohum ve pozlama bu kurulumla kaydedilir. Yalnızca etkin görüntü yöntemi kaydedilir.", "render.gpu_path_failed": "GPU yol izleyici kullanılamıyor", @@ -2379,6 +2627,7 @@ "render.heading_results": "SONUÇLAR", "render.heading_shots": "ÇEKİMLER", "render.hide_shots_label": "Çekim şeridini gizle", + "render.hover_look": "{object} · {look} · seçmek için tıklayın", "render.image_advanced": "Gelişmiş GPU kontrolleri", "render.image_cancelled": "Render iptal edildi", "render.image_cancelling": "İptal ediliyor", @@ -2525,17 +2774,95 @@ "render.library_uniform_ack": "Uzamsal veya gelişmiş efektler olmadan tek biçimli yaklaşımı kullan", "render.library_usable": "Çalıştırılabilir yansıtmalar", "render.library_users": "Kullanan gövdeler", + "render.light_aim": "Şuna yönelt", "render.light_anchor_pending": "Işık koordinat çerçevesini değiştirmek henüz desteklenmiyor.", + "render.light_azimuth": "Azimut", + "render.light_backend_limit": "Işıkları düzenlemek için CPU ışın izleme veya açık bir GPU stüdyosu seçin. Eski GPU stüdyo ışıkları salt okunur kalır.", + "render.light_behaviour": "DAVRANIŞ", + "render.light_default_fill": "Dolgu ışığı", + "render.light_default_key": "Ana ışık", + "render.light_default_number": "Işık {number}", + "render.light_default_rim": "Kenar ışığı", + "render.light_delete": "Sil", + "render.light_distance": "Mesafe", + "render.light_duplicate": "Çoğalt", + "render.light_elevation": "Yükseklik açısı", + "render.light_gpu_punctual": "Nokta ve Spot ışıklar CPU ışın izleme ile kullanılabilir.", + "render.light_hard_shadow": "Bu ışığın gölgeleri serttir; ışık veren yumuşaklığı kullanılamıyor.", + "render.light_intensity": "Şiddet", + "render.light_missing": "Bu ışık artık yok. Tarayıcıda bir ışık seçin.", + "render.light_place_highlight": "Vurguya göre yerleştir", + "render.light_place_pending": "Vurguya göre yerleştirme, görünüm alanında ışık seçmeyi gerektirir ve henüz desteklenmiyor.", + "render.light_position": "KONUM", + "render.light_preview_help": "Canlı görünüm gölgesizdir; Alan ışıkları merkez örnekli bir yaklaşım kullanır. Render, tam ışık modelini kullanır.", + "render.light_product_centre": "Ürün merkezi", + "render.light_size": "Boyut", + "render.light_softness": "Yumuşaklık", + "render.light_temperature": "Renk sıcaklığı", + "render.light_temperature_help": "Yaklaşık kara cisim tonu; doğrusal RGB olarak kaydedilir.", + "render.light_type": "Tür", + "render.light_type_area": "Alan", + "render.light_type_point": "Nokta", + "render.light_type_spot": "Spot", + "render.light_type_sun": "Güneş", + "render.light_view_only": "Solo ve susturma bu görünümü ve render'larını etkiler; tasarıma asla kaydedilmez.", + "render.light_world_origin": "Dünya orijini", + "render.lighting_aim_point": "Hedef noktası", + "render.lighting_apply": "Ön ayarı uygula", + "render.lighting_area_samples": "Alan örnekleri", + "render.lighting_budget": "Bütçe", + "render.lighting_budget_help": "Sayılar salt okunurdur. Değiştirmek için ışık ekleyin, çoğaltın veya silin; sınırları yöntem belirler.", + "render.lighting_colour": "Renk", + "render.lighting_consent": "Yönlü ışıkları bu ön ayarla değiştir", + "render.lighting_custom": "Özel ışıklar", + "render.lighting_custom_help": "Özel'i seçmek, ön ayarı düzenlenebilir ışıklara dönüştürür; ağaçta her biri için bir satır.", + "render.lighting_direction": "Yön X Y Z", + "render.lighting_direction_help": "Ya da ışığa doğru bir yön yazın (X Y Z).", + "render.lighting_distance_unit": " × boyut", + "render.lighting_edge_u": "Kenar U vektörü", + "render.lighting_edge_v": "Kenar V vektörü", + "render.lighting_falloff": "Zayıflama", + "render.lighting_falloff_help": "Şiddet, referans mesafede belirtilir.", + "render.lighting_height": "Yükseklik V", + "render.lighting_minimum": "En az", + "render.lighting_mute": "Bu ışığı sustur", + "render.lighting_point": "Nokta", + "render.lighting_preset": "Stüdyo ön ayarı", + "render.lighting_preset_cpu": "Yönlü stüdyo ön ayarları CPU ışın izleme gerektirir. GPU aydınlatması açık Güneş ve Alan ışıkları kullanır.", + "render.lighting_preset_row": "Ön ayar", + "render.lighting_radiance": "Işınım", + "render.lighting_reference": "Referans", + "render.lighting_review_help": "Yönlü ışık listesinin yerini alır. Nokta ve alan ışıklarını, ortamı, gölgeleri, kamerayı ve diğer tüm çekim ayarlarını korur.", + "render.lighting_review_title": "Aydınlatma ön ayarını incele", + "render.lighting_samples_cpu": "Alan ışığı örnekleri CPU ışın izlemeye uygulanır.", + "render.lighting_samples_need_area": "Alan örneklerini ayarlamak için bir CPU alan ışığı ekleyin.", + "render.lighting_samples_reason": "Alan örnekleri 1, 4, 16 veya 64 olmalıdır.", + "render.lighting_shadow_cpu": "Gölge anahtarı CPU ışın izleme gerektirir. GPU yol izleme her zaman gölge düşürür.", + "render.lighting_shadows": "Gölge düşürsün", + "render.lighting_shadows_section": "Gölgeler ve örnekleme", + "render.lighting_shape": "Şekil", + "render.lighting_solo": "Bu ışığı solo yap", + "render.lighting_source": "Kaynak", + "render.lighting_source_section": "Işık kaynağı", + "render.lighting_spot_preserved": "Bu nokta ışık, türü değiştirilene kadar kaydedilmiş spot konisini korur.", + "render.lighting_sun_distance": "Güneş yönü mesafeden ve hedef noktasından bağımsızdır.", + "render.lighting_unsupported": "desteklenmiyor", + "render.lighting_used": "{used} / {limit}", + "render.lighting_width": "Genişlik U", + "render.lighting_world_position": "Dünya X Y Z", "render.lights_fixed_to": "Işıklar şuna sabit", "render.linear_help": "Giderme, pozlama, ton eşleme, filtreler ve arka plandan önce 32 bit float RGBA ana görüntüyü saklar. Doğrusal sRGB/D65; kapsama ile ön çarpımlı. Ek bellek açıkça kabul edilir. Sıkıştırma veya ek geçiş yoktur.", "render.linear_retain": "Ham doğrusal EXR'ı sakla", "render.linear_unavailable": "Seçilen tamamlanmış karede saklanmış EXR yok. Render'dan önce saklamayı etkinleştirin; eski bir PNG ham ışınımı geri getiremez.", "render.look_add": "Renk görünümü ekle", + "render.look_applied": "{name} uygulandı", "render.look_bleach": "Bleach-bypass esinli", "render.look_cool": "Soğuk", "render.look_cyanotype": "Siyanotip esinli", "render.look_faded": "Solmuş baskı", + "render.look_material": "Malzeme", "render.look_mono": "Tek renk", + "render.look_pasted": "Görünüm yapıştırıldı", "render.look_sepia": "Sepya", "render.look_strength": "Görünüm şiddeti", "render.look_teal_orange": "Turkuaz / turuncu", @@ -2611,9 +2938,11 @@ "render.output_advanced": "Gelişmiş", "render.output_backend_ready": "Hazır", "render.output_check": "Kontrol", + "render.output_check_failed": "Kontrol · hata", "render.output_check_help": "Render başlatmadan bu isteği gözden geçirmek için Kontrol'ü çalıştırın.", "render.output_check_summary": "Kontrol · Notlar: {count}", "render.output_copy": "Kopyala", + "render.output_copy_report": "Raporu kopyala", "render.output_destination": "Hedef", "render.output_destination_help": "Tamamlanan görüntüyü dışa aktarırken hedef dosyayı seçin.", "render.output_dimensions": "Boyut", @@ -2623,6 +2952,7 @@ "render.output_fit": "Sığdır", "render.output_format": "Biçim", "render.output_format_status": "PNG, tamamlanmış tam görüntü görüntüsüdür. İsteğe bağlı GPU EXR, görüntü filtrelerinden önce ham sahne-doğrusal FLOAT32 ışınımını saklar. Dışa aktarma, önizleme önbelleğini veya başka bir işi asla ikame etmez.", + "render.output_grading": "Derecelendirme", "render.output_image": "Görüntü", "render.output_largest": "En çok {width} × {height}", "render.output_lock_ratio": "En-boy oranını kilitle", @@ -2632,6 +2962,8 @@ "render.output_quality_waiting": "kalite sözleşmesi bekleniyor", "render.output_ratio_refused": "Bağlı boyut, bu yöntemin boyut sınırını aşıyor. Önceki boyut korunur; oranı kilitten çıkarın veya daha küçük bir değer seçin.", "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_ok": "Boyut uygun · {width} × {height} px", "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.", @@ -2648,6 +2980,7 @@ "render.page_output": "Çıktı", "render.page_scene": "Sahne", "render.paint_label": "Boya", + "render.painting_with": "{name} ile boyanıyor", "render.passive_stage": "Zemin ve pasif sahne", "render.passive_stage_help": "Sonlu pasif paneller yalnızca render görüntülerine aittir. CAD gövdesi veya kütle eklemezler. Uygulamadan sonra yerleşim sabittir; ürün sınırlarıyla kesişen reddedilir. CPU dağınık sekme aydınlatması sağlamaz.", "render.perspective": "Perspektif kamera", @@ -2669,6 +3002,7 @@ "render.pick_help": "Gövde, Yüz veya Bileşen seçin. Temizlemek için boş alana tıklayın; Shift veya Ctrl ekler ya da seçimi tersine çevirir. Vurgular yalnızca canlı ürün görünümünde görünür.", "render.pick_label": "Seç", "render.pick_up_label": "Al", + "render.pick_up_prompt": "Görünümünü almak için bir nesneye tıklayın", "render.pick_waiting": "Ürün seçimi henüz desteklenmiyor.", "render.post_contrast": "Kontrast", "render.post_gain_b": "Mavi kazancı", @@ -2708,13 +3042,17 @@ "render.radial_distortion": "Radyal bozulma", "render.radial_fit": "Ürünü Sığdır", "render.radial_save": "Çekimi Kaydet", + "render.recent_looks": "Son görünümler", "render.regrade_action": "Ham ana görüntüyü yeniden derecelendir (giderme yok)", "render.regrade_help": "Bu sunum kontrollerini kullanarak seçilen tamamlanmış ham ana görüntüden ayrı bir PNG oluşturur. Işın izlemez, yakalanan boyutu/kamerayı/ışıkları değiştirmez veya EXR'ın üzerine yazmaz. Giderme yeniden uygulanmaz: ham ana görüntüler onun kılavuzlarını saklamaz. Türev, aynı iş kontrollerinden dışa aktarılabilir kalır.", "render.regrade_label": "Yeniden derecelendir", "render.reload_settings": "Kaydedilmiş ayarları yükle", + "render.remove_override": "Geçersiz Kılmayı Kaldır", + "render.replace_all": "Tümünü Değiştir", "render.replace_ground": "Mevcut sahnenin değiştirilmesini onayla", "render.replace_label": "Değiştir", "render.replace_saved_surface": "Bu hedef için mevcut kaydedilmiş bağlamın yerini al", + "render.replace_with": "Şununla değiştir", "render.restore_camera": "Kaydedilmiş kamerayı yükle", "render.result_actual": "%100", "render.result_actual_help": "Ekran pikseli başına bir görüntü pikseli.", @@ -2766,14 +3104,28 @@ "render.saved_surface_remove": "Kaydedilmiş bağlamı kaldır", "render.saved_surfaces": "Kaydedilmiş yüzey bağlamları", "render.saved_surfaces_help": "Sabitlenmiş yerleşik haritalar, yerel milimetrelerde seçilen tekrarı izler. Değiştirilmiş veya eksik hedef bir hatadır, asla otomatik yeniden atama değildir.", + "render.scene_all_shots": "Tüm çekimler ({count})", "render.scene_apply": "İncelenen stüdyoyu uygula", + "render.scene_batch_reason": "Tüm çekimler, henüz kullanılamayan kayıtlı çekim render kuyruğunu gerektirir.", + "render.scene_check_now": "Şimdi kontrol et", + "render.scene_check_passed": "Kontrol geçti · {notes} not", + "render.scene_check_stale": "Kontrol güncel değil · yeniden kontrol edin", + "render.scene_check_unchecked": "Henüz kontrol edilmedi · 0 not", "render.scene_consent": "Aydınlatma, ortam, pozlama ve sahne değiştirmeyi onayla", + "render.scene_light_summary": "{environment} · {lights} ışık", + "render.scene_look_summary": "{looks} görünüm · {overrides} geçersiz kılma", + "render.scene_open_check": "Kontrolü Aç", "render.scene_preserved_fields": "Kamera, örnekleme, boyutlar, optik bağlamlar, CAD geometrisi ve mühendislik malzemeleri korunur.", "render.scene_preserves_design": "Görünen konu için aydınlatma ve pasif bir sahne oluşturun. Geometri, mühendislik malzemeleri ve kamera ayrı kalır.", + "render.scene_ready": "Render için hazır", + "render.scene_reminder": "Sahne tasarımı korur. Geometri, mühendislik özellikleri ve CAD, Render'dan asla değiştirilmez.", + "render.scene_reminders": "Hatırlatmalar", + "render.scene_renderer_ready": "{renderer} hazır", "render.scene_replaces": "Şunların yerini alır", "render.scene_review": "Çöz ve incele", "render.scene_review_help": "Uygulamadan önce tam yerli kompozisyonu inceleyin. Bağlanmamış gelişmiş SCENE özellikleri ikame edilmez.", "render.scene_scope_details": "Kompozisyon kapsama alanı", + "render.scene_stages": "Aşamalar", "render.search_appearances": "{count} görünümde ara", "render.setup_legacy_environment": "Bu eski kurulum, eski ortam dönüşü veya şiddeti kullanıyor. O değerler korunur; tam bir görüntü işi kaydetmeden önce onları açıkça dönüştürün.", "render.setup_name": "Kurulumun adı", @@ -2786,7 +3138,22 @@ "render.shell_tab_camera": "RENDER KAMERASI", "render.shell_tab_lighting": "AYDINLATMA", "render.shell_tab_scene": "RENDER SAHNESİ", + "render.shot_aperture": "Diyafram", + "render.shot_distance": "Mesafe", + "render.shot_dof": "Alan derinliği", "render.shot_duplicate": "Kaydedilmiş çekimi çoğalt", + "render.shot_edit_output": "Çıktı'da düzenle", + "render.shot_exposure_reason": "Pozlama, CPU ışın izleme veya GPU yol izleme gerektirir.", + "render.shot_exposure_section": "Pozlama", + "render.shot_focus_distance": "Odak mesafesi", + "render.shot_fov_reason": "Görüş alanı perspektif projeksiyon gerektirir.", + "render.shot_frame_subject": "Konuyu çerçevele", + "render.shot_invalid_camera": "Kamera değerleri sonlu ve aralık içinde olmalıdır.", + "render.shot_lens": "Mercek", + "render.shot_lens_enable_reason": "Diyaframı ve odak mesafesini düzenlemek için alan derinliğini etkinleştirin.", + "render.shot_lens_gpu_reason": "Alan derinliği GPU yol izleme gerektirir.", + "render.shot_lens_perspective_reason": "Alan derinliği perspektif projeksiyon gerektirir.", + "render.shot_look_selection": "Seçime bak", "render.shot_manager": "Kaydedilmiş çekimler", "render.shot_new_name": "Yeni çekim adı", "render.shot_remove": "Kaydedilmiş çekimi kaldır", @@ -2794,6 +3161,9 @@ "render.shot_remove_title": "Kaydedilmiş çekim kaldırılsın mı?", "render.shot_rename": "Kaydedilmiş çekimi yeniden adlandır", "render.shot_saved_only": "Önce geçerli taslağı kaydedin. Çoğalt ve yeniden adlandır, tam kaydedilmiş seçenekleri ve kaynak referanslarını korur.", + "render.shot_selection_reason": "Hedeflemek için bir gövde veya yüz seçin.", + "render.shot_size": "Boyut", + "render.shot_target": "Hedef", "render.shots_camera_changed": "Bu çekim kaydedildikten sonra kamera değişti", "render.shots_name_help": "Geçerli çekim için benzersiz bir ad girin.", "render.shots_no_result": "Henüz render yok", @@ -3016,6 +3386,13 @@ "render.wait_pick_up": "henüz desteklenmiyor: ürün seçimi ve görünüm örnekleme", "render.wait_replace": "henüz desteklenmiyor: bir görünümün tüm kullanımlarını değiştirme", "render.wait_solo": "henüz desteklenmiyor: ışık görünürlüğü ve solo denetimleri", + "render.wear_applies_to": "Uygulandığı yer", + "render.wear_layer_count": "{count} katman", + "render.wear_layer_one": "{count} katman", + "render.wear_layers": "Katmanlar", + "render.wear_no_layers": "Katman yok", + "render.wear_selected_layer": "Seçili katman", + "render.wear_title": "Aşınma", "sample.bracket.about": "2 mm sac metal kuralına göre kenar flanşları ve bir kenet içeren taban flanşı", "sample.bracket.name": "Sac metal köşebent", "sample.enclosure.about": "Dudak, vida göbekleri, dayanak ve geçmeli bağlantı içeren, içi boşaltılmış blok", @@ -3049,6 +3426,7 @@ "select an axis": "bir eksen seçin", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "önce bir eksen, düz veya dairesel bir kenar ya da silindir, koni veya torus yüzü seçin", "select bodies first": "önce gövdeleri seçin", + "select components first": "önce bileşenleri seçin", "select corner points, or the two lines of one corner": "köşe noktalarını veya bir köşenin iki çizgisini seçin", "select one corner point, or the two lines of one corner": "bir köşe noktasını veya bir köşenin iki çizgisini seçin", "select sheet edges first": "önce sac kenarlarını seçin", @@ -3067,6 +3445,7 @@ "settings.double_click_components": "Bir bileşeni etkinleştirmek veya bağlı bir bileşeni yerinde düzenlemek için çift tıklayın", "settings.frame_rate_details_note": "Son karelerin sürelerinin grafiğini üzerine çizilen üçgenlerle, bir kare süresi histogramını ve GPU'nun çizdiklerini ekler. Göstermek veya gizlemek için görünümdeki kare hızına tıklayın.", "settings.frame_rate_note": "Görünümün sağ alt köşesinde saniyedeki kare sayısını gösterir.", + "settings.insert_lists_open_designs": "Tasarım ekle, diğer sekmelerde açık olan tasarımları önce listeler", "settings.measure_axis_distances": "X/Y/Z mesafelerini göster", "settings.measure_axis_distances_help": "Ölç açıkken toplam mesafenin yanında dünya ekseni bileşen ölçülerini gösterir.", "settings.msaa_applies_on_restart": "Kenar yumuşatma, SolidCraft bir sonraki başlatıldığında uygulanır", @@ -3091,6 +3470,10 @@ "shortcut.unpin": "Sabitlemeyi kaldır", "sketch is missing": "eskiz eksik", "sketch.degrees_of_freedom": "{count} serbestlik derecesi", + "sketch.dimension_added_driven": "Eskiz bu ölçüyü zaten belirliyor, bu yüzden türetilmiş (referans) ölçü olarak eklendi", + "sketch.dimension_applied": "Ölçü uygulandı", + "sketch.dimension_applied_defined": "Ölçü uygulandı; `{name}` parametresi tanımlandı", + "sketch.dimension_applied_renamed": "Ölçü uygulandı; parametreye `{name}` adı verildi", "sketch.dof_left": "Kalan serbestlik derecesi: {count}", "sketch.dof_left_one": "Kalan serbestlik derecesi: {count}", "sketch.draw_curves": "{linetype} olarak çiz", @@ -3125,6 +3508,8 @@ "sketch_text.use_font_file": "{path} yazı tipi dosyasını kullan", "solid": "katı", "status.editing": "{name} düzenleniyor", + "status.navigation": "Fare eşlemesi: {mapping}", + "status.navigation_tip": "Farenin görünümü nasıl hareket ettirdiği. Başka bir eşleme seçmek için tıklayın", "status.under_constrained": "Eksik kısıtlanmış ({count} serbestlik derecesi)", "status.under_constrained_one": "Eksik kısıtlanmış ({count} serbestlik derecesi)", "status.units_tip": "Tasarımın birimleri. Değiştirmek için tıklayın", @@ -3158,16 +3543,20 @@ "timeline.delete_folders_tip": "Özelliklerini ve iç içe klasörlerini geçmişte tut", "timeline.delete_folders_with_features_tip": "Klasörleri, içlerindeki her özellikle ve iç içe klasörleriyle birlikte sil", "timeline.delete_many": "{count} Seçili Özelliği Sil", + "timeline.edited_rolled_back": "{name} değişti, ancak geçmiş işaretçisinden sonra olduğu için oluşturulmadı: sonucu görmek için işaretçiyi onun ötesine taşıyın", "timeline.feature_error": "Hata: {message}", "timeline.feature_hint": "Düzenlemek için çift tıklayın · sıralamak için sürükleyin · daha fazlası için sağ tıklayın", + "timeline.feature_rolled_back": "Geri sarıldı: geçmiş işaretçisinden sonra, bu yüzden oluşturulmadı", "timeline.feature_warning": "Uyarı: {message}", "timeline.group": "Yeni özellik klasörü oluştur", "timeline.move_many": "Zaman çizelgesi öğelerini taşı", "timeline.move_to_group": "Özellikleri klasöre taşı", "timeline.rename_group": "Özellik klasörünü yeniden adlandır", "timeline.reorder_many": "Özellikleri yeniden sırala", + "timeline.suppress_features": "Özellikleri bastır", "timeline.suppress_folders_tip": "Klasörlerdeki ve iç içe klasörlerindeki her özelliği bastır veya bastırmayı kaldır", "timeline.ungroup": "Özellik klasörünü kaldır", + "timeline.unsuppress_features": "Özelliklerin bastırmasını kaldır", "to the face": "yüze kadar", "to the next face": "sonraki yüze kadar", "toolbar.customize_note": "{tab} sekmesi. Bir komuta araç çubuğu düğmesi eklemek için komutu işaretleyin. Ayrıca panelin açılır menüsündeki bir satırı araç çubuğuna sürükleyebilir, bir düğmeyi kaldırmak için araç çubuğunun dışına sürükleyebilir veya açılır menü satırındaki raptiyeyi kullanabilirsiniz.", @@ -3239,6 +3628,10 @@ "ui.3d_mouse_offer": "3D farenizi kalibre etmek, yer değiştirmiş veya ters dönmüş eksenleri düzeltir; hızını ve hissini ayarlar.", "ui.3d_mouse_offer_never": "Bir daha gösterme", "ui.3d_mouse_offer_swap": "Hidraw'a geç (doğrudan sürücü)", + "ui.3d_mouse_orbit": "Döndürme", + "ui.3d_mouse_orbit_classic": "Klasik", + "ui.3d_mouse_orbit_tip": "İzlenen: 3B fare, dönüş başladığında görünümü, görünümün ortasındaki model kısmının etrafında döndürür; bu kısım soluk bir topla işaretlenir. Klasik: görünümün merkezi etrafında", + "ui.3d_mouse_orbit_traced": "İzlenen", "ui.3d_mouse_reading": "Okunan değer", "ui.3d_mouse_reset_axes": "Eksen atamasını sıfırla", "ui.3d_mouse_reset_view": "Görünümü sıfırla", @@ -3275,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.", "ui.3d_mouse_switch_cap": "Merkeze dönüş olarak kalibre et", "ui.3d_mouse_switch_trackball": "İztopu olarak kalibre et", + "ui.3d_mouse_use_direct": "Doğrudan sürücüyü kullan", + "ui.3d_mouse_use_direct_tip": "3D fareyi spacenavd yerine doğrudan (hidraw) oku.", "ui.3d_mouse_view_axis": "Görünüm ekseni", "ui.3d_mouse_view_off": "Kapalı", "ui.3d_mouse_view_off_tip": "3D fare sürücüsünün profili bu ekseni kapatıyor, bu yüzden hareketi görünümü oynatmaz. Burada başka bir eksenin hareketini seçerseniz ikisi yer değiştirir ve o hareket görünümü oynatmayı bırakır.", @@ -3581,6 +3976,8 @@ "ui.cancel": "İptal", "ui.canvases": "Tuvaller", "ui.center": "Merkez", + "ui.center_arc_follows_cursor": "Merkez noktalı yay imlecin yönünü izler", + "ui.center_arc_follows_cursor_tip": "Merkez noktalı yay, imlecin merkezinin etrafında dönüş yönüne göre saat yönünde veya saat yönünün tersine ilerler. Kapalıyken: başlangıçtan itibaren her zaman saat yönünün tersine.", "ui.centerline": "Merkez çizgisi", "ui.chain": "Zincir", "ui.chamfer": "Pah", @@ -3589,6 +3986,7 @@ "ui.circle": "Daire", "ui.circular_pattern": "Dairesel desen", "ui.clear": "Temizle", + "ui.clear_print_face": "Baskı yüzeyini temizle", "ui.clear_recent": "Son kullanılanları temizle", "ui.clear_the_selection": "Seçimi temizle", "ui.click_selects_whole_bodies": "Tıklama tüm gövdeyi seçer", @@ -3623,6 +4021,15 @@ "ui.constant": "Sabit", "ui.constraints_conflict": "Kısıtlamalar çakışıyor", "ui.construction": "Yapı çizgisi", + "ui.construction.angle_hint": "Yardımcı doğru: bir noktaya tıklayın, ardından bir açı yazın veya bir yön seçin", + "ui.construction.bisector_hint": "Açıortay: kesişen iki düz çizgiyi, ardından istenen çözümü seçin", + "ui.construction.common_hint": "Ortak teğet: iki daire/yay, ardından istenen çözümü seçin", + "ui.construction.contact_hint": "Teğet: bir daire/yay veya açık spline/konik eğri, ardından değme noktasını seçin (serbest biçimli eğriler: uç noktalar)", + "ui.construction.external_hint": "Teğet: bir daire veya yay, bir dış nokta, ardından istenen çözümü seçin", + "ui.construction.free_hint": "Yardımcı doğru: iki noktaya tıklayın; yeniden başlamak için sağ tıklayın", + "ui.construction.point_hint": "Yardımcı doğru: geçeceği noktaya tıklayın", + "ui.construction.reference_hint": "Yardımcı doğru: düz bir çizgi, ardından bir nokta seçin; referansı değiştirmek için sağ tıklayın", + "ui.construction.ribbon_label": "Yardımcılar", "ui.context_menu": "Bağlam menüsü", "ui.continuity_g0": "G0 kırık", "ui.continuity_g1": "G1 teğet", @@ -3714,6 +4121,14 @@ "ui.end_scale": "Bitiş ölçeği", "ui.entities": "Öğeler", "ui.equal": "Eşit", + "ui.equation_end": "Bitiş", + "ui.equation_explicit": "Açık fonksiyon", + "ui.equation_form": "Biçim", + "ui.equation_note": "Değişken düz bir sayıdır: sin(t) onu radyan olarak alır. Birimi olmayan bir sonuç tasarımın birimindedir.", + "ui.equation_parametric": "Parametrik", + "ui.equation_x_of_t": "x(t)", + "ui.equation_y_of_t": "y(t)", + "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Eşdeğer yarıçap", "ui.expand": "Genişlet", "ui.exportDxf": "DXF olarak dışa aktar…", @@ -3779,6 +4194,7 @@ "ui.heading_design": "TASARIM", "ui.heading_draft": "KONİKLİK", "ui.heading_edit": "DÜZENLE", + "ui.heading_equation_curve": "DENKLEM EĞRİSİ", "ui.heading_extrude": "EKSTRÜZYON", "ui.heading_fillet": "YUVARLATMA", "ui.heading_finish_editing": "DÜZENLEMEYİ BİTİR", @@ -3870,6 +4286,10 @@ "ui.import_ellipsis": "İçe aktar…", "ui.infinite": "Sonsuz", "ui.insert": "Ekle", + "ui.insert_design.browse": "Göz at…", + "ui.insert_design.open_designs": "Açık tasarımlar", + "ui.insert_design.saved_file": "{name} kaydedilmemiş değişiklikler içeriyor, bu yüzden kaydedilmiş dosyası eklendi. Değişiklikleri almak için onu kaydedin, ardından Bağları güncelle'yi çalıştırın.", + "ui.insert_design.unsaved": "Kaydedilmemiş değişiklikler", "ui.inspect": "İncele", "ui.intersect": "Kesişim", "ui.isolate": "Yalıt", @@ -4013,12 +4433,29 @@ "ui.polygon": "Çokgen", "ui.preferences": "Ayarlar", "ui.press_a_key": "bir tuşa basın…", + "ui.print_face_cleared": "{body}: baskı yüzeyi temizlendi", + "ui.print_face_input": "Baskı yüzeyi", + "ui.print_face_one_body": "Baskı yüzeyi tek bir gövdeye aittir: değiştirmek için o gövdeyi tek başına gönderin", + "ui.print_face_pick": "Baskı yüzeyi yapmak için katı bir gövdenin düz bir yüzeyini seçin", + "ui.print_face_set": "{body}: baskı yüzeyi ayarlandı; Dilimleyiciye gönder gövdeyi bu yüzeyin üzerine koyar", "ui.profile": "Profil", "ui.profile_plane": "Profil düzlemi", "ui.profiles": "Profiller", "ui.project": "Yansıt", "ui.project_face_edges_sketch": "Yüz kenarlarını yeni eskizlere yansıt", "ui.properties": "Özellikler", + "ui.properties.copy_matrix": "Matrisi kopyala", + "ui.properties.first_moments": "Birinci momentler", + "ui.properties.inertia": "Eylemsizlik momenti", + "ui.properties.inertia_note": "Kütle merkezine göre, dünya eksenlerinde (g·{unit}²)", + "ui.properties.inertia_unavailable": "Kullanılamıyor: bir gövdenin ağı kapalı değil.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Ölçülüyor…", + "ui.properties.measuring_failed": "Ölçüm tamamlanmadan durdu.", + "ui.properties.principal_axis": "Asal eksen {number}", + "ui.properties.principal_moments": "Asal momentler", "ui.pull_direction": "Kalıptan çıkarma yönü", "ui.quality": "Kalite", "ui.quantity": "Adet", @@ -4064,6 +4501,7 @@ "ui.revolve": "Döndür", "ui.ribbon_exit": "Çıkış", "ui.ribbon_labels": "Şerit simgelerinin altında metin göster", + "ui.ribbon_menus_skip_pinned": "Sabitlenen komutları açılır menülerin dışında bırak", "ui.ribbon_pin_footer": "Sabitlenen komutlar şeritte görünür. Sabitlemek veya kaldırmak için bir satırın üzerine gelin.", "ui.ribbon_workspace": "Çalışma alanı", "ui.right": "Sağ", @@ -4118,7 +4556,11 @@ "ui.sketch": "Eskiz", "ui.sketch_dimension": "Eskiz ölçüsü", "ui.sketch_grid": "Eskiz ızgarası", + "ui.sketch_intersect_geometry": "Geometri", + "ui.sketch_intersect_projection_link": "Yansıtma bağı", + "ui.sketch_intersect_selection_filter": "Seçim filtresi", "ui.sketch_modifiers": "Eskiz değiştiricileri", + "ui.sketch_project_geometry": "Geometri", "ui.sketch_scale": "Eskiz ölçeği", "ui.sketches": "Eskizler", "ui.slice": "Dilim", @@ -4169,6 +4611,7 @@ "ui.tab_manage": "Yönet", "ui.tab_sheet_metal": "Sac Metal", "ui.tab_view": "Görünüm", + "ui.tab_weldments": "Kaynak", "ui.tangent": "Teğet", "ui.taper_angle": "Konik açı", "ui.tapped": "Kılavuzlu", @@ -4184,6 +4627,7 @@ "ui.tip_angle": "Uç açısı", "ui.to_object": "Nesneye kadar", "ui.tool_direction": "Takım yönü", + "ui.toolbar_tabs_hidden": "Tüm araç çubuğu sekmeleri gizli. Birini göstermek için + işaretine tıklayın veya buraya sağ tıklayın.", "ui.top": "Üst", "ui.top_diameter": "Üst çap", "ui.top_radius": "Üst yarıçap", @@ -4221,6 +4665,8 @@ "ui.view_centre": "Görünüm merkezi", "ui.viewport_status_clear": "Kapat: {name}", "ui.viewport_status_clear_hint": "Kapat: {name} · Esc", + "ui.viewport_status_edges": "{count} kenar", + "ui.viewport_status_from": "{direction} yönünden", "ui.viewport_status_key": "Göstergeyi göster: {name}", "ui.viewport_status_minimum_radius": "Minimum yarıçap", "ui.viewport_status_pairs": "{count} çift", @@ -4230,6 +4676,7 @@ "ui.visual_shaded_with_edges": "Kenarlarla gölgeli", "ui.visual_style": "Görsel stil", "ui.visual_wireframe": "Tel kafes", + "ui.wheel_zoom_speed": "Fare tekerleği yakınlaştırma hızı", "ui.width": "Genişlik", "ui.window_system": "Pencere sistemi", "ui.window_system_auto": "Otomatik", @@ -4260,7 +4707,9 @@ "update.button": "Güncelle", "update.button_version": "{version} sürümüne güncelle", "update.check_automatically": "Güncellemeleri otomatik denetle", + "update.check_for_updates": "Güncellemeleri denetle", "update.check_now": "Şimdi denetle", + "update.check_unavailable": "Bu kurulum için güncelleme denetimi kullanılamıyor.", "update.checking": "Denetleniyor…", "update.copy": "Kopyala", "update.dmg_opened": "Disk görüntüsü açıldı ve doğrulandı: SolidCraft'ı “Uygulamalar” klasörüne sürükleyin, eski sürümü değiştirin ve SolidCraft'ı yeniden başlatın.", @@ -4274,13 +4723,18 @@ "update.installed": "SolidCraft {version} yüklendi. Kullanmak için yeniden başlatın.", "update.installer_started": "Yükleme programı, SolidCraft kapatıldığında başlar.", "update.later": "Daha sonra hatırlat", + "update.managed": "Güncelleme mevcut: SolidCraft'ı {manager} üzerinden güncelleyin", "update.no_notes": "Bu sürüm için not bulunmuyor.", "update.package_hint": "Bu kurulum bir {kind} kaynağından geliyor. Yeni paketi indirip şu komutla yükleyin:", "update.prefs_dev": "Bu bir geliştirme derlemesi ({version}): güncelleme aramaz.", "update.prefs_flatpak": "Bu kurulum ({version}) Flatpak tarafından güncellenir: {command} komutunu çalıştırın veya yazılım yöneticisini kullanın. SolidCraft kendisi denetlemez.", "update.prefs_label": "Güncellemeler", + "update.prefs_managed": "Bu, {manager} tarafından yönetilen SolidCraft {version}. Denetim, GitHub'a en yeni sürümü sorar ve başka hiçbir şey göndermez; SolidCraft'ı {manager} üzerinden güncelleyin.", "update.prefs_note": "Bu, SolidCraft {version} ({kind}). Denetim, GitHub'a en yeni sürümü sorar ve başka hiçbir şey göndermez.", "update.prefs_web": "En yeni web uygulamasını almak için sayfayı yeniden yükleyin. Ağ kullanılamazsa veya güncelleme indirmesi zamanında bitiremezse bunun yerine kaydedilmiş sürüm açılır; süren bir indirme bir sonraki yeniden yükleme için devam eder.", + "update.refresh_checking": "En yeni sürüm denetleniyor…", + "update.refresh_fallback": "Daha yeni bir sürüm denetlenemedi; daha önce bulunan {version} sürümüne güncelleniyor.", + "update.refresh_selected": "{version} sürümüne güncelleniyor.", "update.release_page": "Sürüm sayfasını aç", "update.restart": "Şimdi yeniden başlat", "update.restart_later": "Daha sonra", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 1f27581317..7431f51f3e 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "Проєкти, які ви відкриваєте чи зберігаєте, з'являються тут.", "Determined by To Object. Choose Angle to edit the angular extent.": "Визначається об'єктом призначення. Виберіть «Кут», щоб редагувати кутову протяжність.", "Diameter": "Діаметр", + "Did this assembly open the wrong way up?": "Цю збірку відкрито догори ногами?", + "Did this part open the wrong way up?": "Цю деталь відкрито догори ногами?", "Dimension new shapes": "Проставляти розміри нових фігур", "Direction": "Напрямок", "Discard": "Відхилити", @@ -204,6 +206,7 @@ "Edges": "Ребра", "Edit": "Змінити", "Edit Profile Sketch": "Редагувати ескіз профілю", + "Edit shortcuts": "Редагувати комбінації клавіш", "Enable rule": "Увімкнути правило", "Enabled": "Увімкнено", "End Radius": "Кінцевий радіус", @@ -466,6 +469,7 @@ "Radius": "Радіус", "Radius Fit": "Підгонка за радіусом", "Ratio": "Співвідношення", + "Read-only list: shortcuts can't be changed here.": "Список лише для перегляду: тут не можна змінювати комбінації клавіш", "Reattach selected face": "Повторно приєднати вибрану грань", "Recover": "Відновити", "Recover unsaved design?": "Відновити незбережений проєкт?", @@ -599,6 +603,7 @@ "Square": "Квадрат", "Stand it up": "Вирівняти", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Вирівняти: поворот на чверть оберту навколо X як одна операція переміщення, яку можна скасувати.", + "Stand this assembly up?": "Вирівняти збірку по вертикалі?", "Stand this part up?": "Вирівняти цю деталь?", "Start angle": "Початковий кут", "Start bulge": "Роздуття початку", @@ -649,6 +654,7 @@ "Third point": "Третя точка", "This child is shared. Deleting it removes it from every copy of its parent.": "Цей дочірній об'єкт спільний. Його видалення прибирає його з кожної копії батьківського.", "This child is shared. Position changes apply to every copy of its parent.": "Цей дочірній об'єкт спільний. Зміни положення застосовуються до кожної копії батьківського.", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "Цей компонент розташований у підзбірці, що використовується кілька разів. Вирівнювання перемістить його в усіх екземплярах.", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ця грань не може забезпечити надійну прив'язку. Виберіть іншу грань або явно використайте фіксовану площину.", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Це зображення ще не додано до GitHub. Скопіюйте або збережіть його, а потім додайте на GitHub.", "Thread depth": "Глибина різьби", @@ -769,6 +775,8 @@ "at the face": "біля грані", "bom.group_children": "Піддеталі в рядку їхньої деталі", "browser.body_name": "Тіло{number}", + "browser.calibrate_click_a": "Клацніть зображення для точки A або введіть її координати.", + "browser.calibrate_click_b": "Клацніть зображення для точки B або введіть її координати.", "browser.calibrate_title": "Калібрувати: {name}", "browser.capture_many": "Компоненти ({count}) переміщено, але їхні положення не зафіксовано.", "browser.capture_note": "«Зафіксувати положення» зберігає нове положення; «Відновити положення» повертає попереднє.", @@ -790,6 +798,11 @@ "cam.roller": "Ролик", "cam.roller_pick": "Ролик (необов'язково)", "cam.tip_pick": "Вістря (необов'язково)", + "camera.shot_pitch": "Тангаж", + "camera.shot_roll": "Крен", + "camera.shot_yaw": "Рискання", + "canvas.decal_place_hint": "Клацніть по грані, щоб розмістити декаль", + "canvas.place_hint": "Клацніть для розміщення", "choose a profile": "виберіть профіль", "choose the cam's and the follower's joints": "виберіть з'єднання кулачка й штовхача", "choose the joints to couple": "виберіть з'єднання для зв'язування", @@ -797,12 +810,16 @@ "click sketch curves": "клацніть криві ескізу", "cmd.appearance_assign": "Вигляд", "cmd.appearance_chain": "Ланцюг вигляду", + "cmd.appearance_copy": "Копіювати вигляд", + "cmd.appearance_favorite": "Додати до обраного", "cmd.appearance_library": "Бібліотека виглядів", "cmd.appearance_list": "Список виглядів", "cmd.appearance_paint": "Нанести вигляд", + "cmd.appearance_paste": "Вставити вигляд", "cmd.appearance_pick_up": "Взяти вигляд", "cmd.appearance_replace": "Замінити вигляд", "cmd.assembly_bom": "Специфікація матеріалів", + "cmd.body.print_face": "Грань друку", "cmd.body_rename": "Перейменувати тіло", "cmd.browser_collapse_all": "Згорнути все", "cmd.browser_expand_all": "Розгорнути все", @@ -810,6 +827,7 @@ "cmd.browser_move": "Перемістити елементи браузера", "cmd.browser_order": "Упорядкувати елементи браузера", "cmd.browser_rename_group": "Перейменувати групу", + "cmd.browser_reparent": "Перемістити до компонента", "cmd.browser_ungroup": "Розгрупувати", "cmd.browser_visibility": "Показати/приховати", "cmd.canvas_calibrate": "Калібрувати полотно", @@ -836,7 +854,9 @@ "cmd.component_metadata": "Властивості деталі", "cmd.component_metadata_set": "Змінити властивості деталі", "cmd.component_move_bodies": "Перемістити тіла до компонента", + "cmd.component_move_features": "Перемістити операції до компонента", "cmd.component_move_sketches": "Перемістити ескізи до компонента", + "cmd.component_paste_from_design": "Вставити з іншого проєкту як компонент", "cmd.component_paste_new": "Вставити як новий", "cmd.component_properties": "Фізичні властивості компонента", "cmd.component_relink": "Переприв'язати джерело", @@ -973,6 +993,7 @@ "cmd.motion_trace": "Траєкторія руху", "cmd.motion_trace_edit": "Змінити траєкторію руху", "cmd.motion_trace_keep": "Зберегти траєкторію руху", + "cmd.occurrence_align": "Вирівняти екземпляр", "cmd.occurrence_copy": "Вставити (екземпляр)", "cmd.occurrence_drag": "Перетягнути екземпляр", "cmd.occurrence_ground": "Закріпити", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "Видалити прив'язку поверхні", "cmd.render_library_targets": "Знайти цілі бібліотеки рендерингу", "cmd.render_lights_add": "Додати джерело світла", + "cmd.render_lights_duplicate": "Дублювати джерело світла", + "cmd.render_lights_list": "Список джерел світла", + "cmd.render_lights_mute": "Вимкнути/увімкнути джерело світла", + "cmd.render_lights_remove": "Видалити джерело світла", + "cmd.render_lights_set": "Налаштувати джерело світла", "cmd.render_lights_solo": "Лише це світло", "cmd.render_optics_bind": "Зберегти оптичну прив'язку", "cmd.render_optics_unbind": "Видалити оптичну прив'язку", "cmd.render_output_size": "Задати розмір виведення рендерингу", "cmd.render_preflight": "Перевірити запит на рендеринг", + "cmd.render_preflight_copy": "Копіювати дані попередньої перевірки", "cmd.render_preset_apply": "Застосувати вбудовану студію", "cmd.render_preset_resolve": "Переглянути вбудовану студію", "cmd.render_quality_prepare": "Переглянути фіксований бюджет вибірки", @@ -1057,6 +1084,13 @@ "cmd.render_start": "Рендеринг зображення", "cmd.render_status": "Перебіг рендерингу", "cmd.select_clear": "Зняти виділення", + "cmd.settings_profile_apply": "Застосувати профіль налаштувань", + "cmd.settings_profile_delete": "Видалити профіль налаштувань", + "cmd.settings_profile_export": "Експортувати профіль налаштувань", + "cmd.settings_profile_import": "Імпортувати профіль налаштувань", + "cmd.settings_profile_list": "Профілі налаштувань", + "cmd.settings_profile_rename": "Перейменувати профіль налаштувань", + "cmd.settings_profile_save": "Зберегти профіль налаштувань", "cmd.sheet_convert": "Перетворити на листовий метал", "cmd.sheet_export_bend_table": "Експортувати таблицю згинів", "cmd.sheet_export_dxf": "Експортувати розгортку як DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "Зігнути назад", "cmd.sheet_rules": "Список правил листового металу", "cmd.sheet_unfold": "Розгорнути", + "cmd.sketch.construction_line": "Допоміжна пряма", + "cmd.sketch.construction_line.angle": "Допоміжна пряма під кутом", + "cmd.sketch.construction_line.bisector": "Бісектриса", + "cmd.sketch.construction_line.horizontal": "Горизонтальна допоміжна пряма", + "cmd.sketch.construction_line.parallel": "Паралельна допоміжна пряма", + "cmd.sketch.construction_line.perpendicular": "Перпендикулярна допоміжна пряма", + "cmd.sketch.construction_line.tangent_at": "Дотична в точці кривої", + "cmd.sketch.construction_line.tangent_external": "Дотична через зовнішню точку", + "cmd.sketch.construction_line.tangent_two": "Спільна дотична", + "cmd.sketch.construction_line.vertical": "Вертикальна допоміжна пряма", "cmd.sketch_arc3d": "3D-дуга", "cmd.sketch_arc_center_point": "Дуга за центром", "cmd.sketch_arc_tangent": "Дотична дуга", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "Розмір креслення", "cmd.sketch_edit": "Редагувати ескіз", "cmd.sketch_edit_text": "Редагувати текст", + "cmd.sketch_equation_curve": "Крива за рівнянням", + "cmd.sketch_equation_curve_edit": "Редагувати криву за рівнянням", "cmd.sketch_export_dxf": "Зберегти як DXF", "cmd.sketch_finish_auto_constrain": "Завершити з автообмеженням", "cmd.sketch_fit_curves_to_section": "Підігнати криві до перерізу сітки", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "Браузер рендерингу", "cmd.view.render_dock": "Перемістити док рендерингу", "cmd.view.render_focus": "Вид фокусу рендерингу", + "cmd.view.status_items": "Елементи рядка стану", "cmd.view_ambient_occlusion": "Окружне затінення", "cmd.view_angle_readout": "Кути виду", "cmd.view_apply": "Відновити іменований вид", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "Перпендикулярно до виділення", "cmd.view_previous_tab": "Попередня вкладка", "cmd.view_rename": "Перейменувати іменований вид", + "cmd.view_render_frame": "Кадр візуалізації", "cmd.view_right": "Вид справа", "cmd.view_save": "Новий іменований вид", "cmd.view_shots_strip": "Стрічка кадрів", @@ -1229,10 +1277,14 @@ "color.copy": "Клацніть, щоб скопіювати значення кольору", "color.edit": "Клацніть, щоб змінити колір", "color.gamma": "Значення кольору в гамма-просторі 0-255", + "color.hex": "HEX-код", + "color.hex_help": "Введіть колір у форматі #RRGGBB або #RRGGBBAA", + "color.hex_hint": "#rrggbb або #rrggbbaa", "color.hue": "Відтінок", "color.linear": "Значення кольору в лінійному просторі 0-1", "color.saturation_value": "Насиченість і яскравість", "color.selected": "Вибраний колір", + "config.column_filter_empty": "Немає збігів", "contact.components_count": "Компонентів: {count}", "contact.enabled_label": "увімкнено", "coupling.gear_1": "Шестерня 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "Двічі клацніть інший керівний розмір, щоб вставити його назву", "dialog.dimension_name": "Розмір: {name}", "dialog.edit_title": "РЕДАГУВАННЯ {title}", + "dialog.no_shown_body": "Немає видимих тіл для виконання операції", "dialog.select": "↖ Вибрати", "dialog.selected_count": "↖ Вибрано: {count}", "dialog.selection_guidance": "Виберіть вхідні дані, щоб змінити їх. Клацніть вибрані об'єкти, щоб прибрати їх; натискання з Ctrl/Cmd також перемикає їх. Перегляньте попередній перегляд, а потім натисніть OK.", @@ -1346,8 +1399,21 @@ "help.material.assign": "Задає, з чого зроблено вибрані тіла, наприклад зі сталі, алюмінію чи ABS. Використовуйте, щоб маса й вага відповідали справжній деталі.", "help.mit": "Ліцензія MIT", "help.notice": "NOTICE", + "help.occurrence.move": "Переміщує екземпляр компонента в збірці", "help.parameters.change": "Відкриває таблицю іменованих значень і виразів, що керують розмірами проєкту. Використовуйте, щоб змінити розмір в одному місці, а всі операції, які його використовують, підлаштувалися.", "help.pointcloud.insert": "Вставте скан у форматі PLY, XYZ або PTS як хмару точок поряд із моделлю. Використовуйте її як довідку під час побудови моделі за виміряними даними.", + "help.sketch.construction_line": "Створює нескінченну допоміжну пряму через дві точки. Використовуйте її для розмітки ескізу без розбиття профілів.", + "help.sketch.construction_line.angle": "Створює нескінченну допоміжну пряму під кутом до осі X ескізу. Виберіть точку й введіть кут або виберіть її напрямок.", + "help.sketch.construction_line.bisector": "Виберіть дві прямі ескізу, що перетинаються, а потім одну з двох бісектрис. Результат змінюється разом із вихідними прямими.", + "help.sketch.construction_line.horizontal": "Створює нескінченну горизонтальну допоміжну пряму через точку. Її горизонтальне обмеження зберігається під час подальших змін.", + "help.sketch.construction_line.parallel": "Виберіть пряму ескізу, потім розмістіть паралельну нескінченну допоміжну пряму. Клацніть правою кнопкою, щоб вибрати інший опорний об'єкт.", + "help.sketch.construction_line.perpendicular": "Виберіть пряму ескізу, потім розмістіть перпендикулярну нескінченну допоміжну пряму. Клацніть правою кнопкою, щоб вибрати інший опорний об'єкт.", + "help.sketch.construction_line.tangent_at": "Створює дотичну в точці кола чи кругової дуги або в кінцевій точці відкритого сплайна чи конічної кривої. Виберіть криву, потім точку дотику.", + "help.sketch.construction_line.tangent_external": "Виберіть коло або кругову дугу, потім зовнішню точку й варіант дотичної. У попередніх переглядах показано лише точки дотику на намальованій дузі.", + "help.sketch.construction_line.tangent_two": "Виберіть два кола або кругові дуги, потім виберіть спільну дотичну в попередньому перегляді. Точки дотику мають лежати на намальованих кривих.", + "help.sketch.construction_line.vertical": "Створює нескінченну вертикальну допоміжну пряму через точку. Її вертикальне обмеження зберігається під час подальших змін.", + "help.sketch.equation_curve": "Створює криву за параметричними рівняннями X(t), Y(t) у площині ескізу.", + "help.sketch.inspect": "Відкриває звіт про відкритий ескіз: скільки ступенів вільності залишилося і чи він повністю обмежений. Використовуйте його, щоб дізнатися, що ще рухається, перш ніж завершити ескіз.", "help.solid.align": "Переміщує вибрані тіла так, щоб точка, грань чи площина на них зійшлася з точкою, гранню чи площиною деінде. Використовуйте, щоб притиснути деталь до іншої, не вимірюючи зсуву.", "help.solid.chamfer": "Зрізає вибрані ребра плоским скосом на заданій відстані. Використовуйте, щоб притупити гострі ребра або полегшити вставляння штифта чи гвинта.", "help.solid.combine": "Об'єднує тіла, вирізає їх із цільового тіла або залишає лише спільний для них об'єм, і може зберегти тіла-інструменти. Використовуйте, щоб з'єднати деталі, вирізати одну форму іншою або знайти, де два тіла перекриваються.", @@ -1374,6 +1440,8 @@ "help.timeline.compute_all": "Перебудовує кожну операцію часового ланцюжка з початку. Використовуйте, щоб оновити модель і побачити, які операції завершуються помилкою.", "help.timeline.delete": "Видаляє вибрані операції з часового ланцюжка; видалення ескізу видаляє й операції, побудовані на ньому. Використовуйте, щоб прибрати крок, який більше не потрібен.", "help.timeline.play": "Відтворює решту операцій по одній, із паузою в півсекунди після кожного перебудування. Виконайте цю команду ще раз або натисніть Esc, щоб зупинити; «Перейти в кінець» переходить одразу.", + "history.sketch_under_constrained": "Ескіз має {count} ступенів вільності", + "history.sketch_under_constrained_one": "Ескіз має {count} ступінь вільності", "hole_presets.confirm_delete": "Підтвердити видалення", "hole_presets.delete": "Видалити", "hole_presets.delete_prompt": "Видалити цей особистий набір? Наявні отвори не зміняться.", @@ -1474,6 +1542,21 @@ "joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.", "joint.rigid_group_one_component": "Усі ці тіла ({count}) містяться в одному компоненті (або безпосередньо в проєкті): спершу скористайтеся {command}.", "joint.rigid_group_select_components": "Виберіть два чи більше компонентів.", + "joint_move.blocked_hint": "Переміщення заблоковано обмеженнями з'єднання", + "joint_move.blocked_title": "Переміщення неможливе", + "joint_move.coarse": "Грубо", + "joint_move.distance": "Відстань", + "joint_move.done": "Готово", + "joint_move.fine": "Точно", + "joint_move.fixed": "Фіксовано", + "joint_move.help": "Перетягуйте або використовуйте кнопки кроку для переміщення з'єднання", + "joint_move.legacy_hint": "Для цього з'єднання доступне лише застаріле керування", + "joint_move.precision": "Точність", + "joint_move.rotation": "Поворот", + "joint_move.select": "Виберіть з'єднання для переміщення", + "joint_move.settings": "Налаштування переміщення", + "joint_move.step": "Крок", + "joint_move.use_handles": "Використовувати маніпулятори", "keymap.command_id": "ID команди", "keymap.empty": "Немає відповідних команд", "keymap.use_anyway": "Усе одно використовувати тут {key}", @@ -1533,6 +1616,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} с тому", @@ -1568,6 +1655,8 @@ "nav.maya.help": "Alt+ліва обертає, Alt+середня панорамує, Alt+права масштабує; середня також панорамує", "nav.openscad.help": "Ліва обертає по порожньому місцю, Shift+ліва виділяє рамкою, геометрію ескізу перетягують для редагування; права або середня панорамує, Shift+права або Shift+середня масштабує", "nav.perspective.sketch": "Редагування ескізу використовує ортографічний вид. Завершіть ескіз, щоб увімкнути перспективу.", + "nav.right_drag_orbits.help": "Перетягування правою кнопкою миші обертає вид", + "nav.right_drag_orbits.label": "Обертання правою кнопкою", "nav.solidworks.help": "Середня обертає, Ctrl+середня панорамує, Shift+середня масштабує, права обертає", "nav.tinkercad.help": "Права обертає, середня або Shift+права панорамує", "nav.touchpad.help": "Утримуйте Shift і рухайте вказівник, щоб панорамувати; утримуйте Alt/Option і рухайте, щоб обертати. Кнопка миші не потрібна. Вертикальна прокрутка масштабує.", @@ -1589,6 +1678,9 @@ "navbar.custom.customise": "Налаштувати панель навігації…", "navbar.custom.design": "Проєктування", "navbar.custom.duplicate": "Цей слот уже є на панелі.", + "navbar.custom.edge": "Край панелі", + "navbar.custom.edge_bottom": "Нижній край", + "navbar.custom.edge_top": "Верхній край", "navbar.custom.fade": "Згасання", "navbar.custom.full": "Приберіть слот, щоб додати інший.", "navbar.custom.hidden_design": "Приховано в проєктуванні", @@ -1695,12 +1787,15 @@ "parts.dimension.thickness": "Товщина", "parts.dimension.width": "Ширина", "parts.family.bearing": "Радіальний кульковий підшипник (габарит)", + "parts.family.button_head_cap_screw": "Гвинт з циліндричною головкою та внутрішнім шестигранником", "parts.family.dowel_pin": "Циліндричний штифт", "parts.family.hex_bolt": "Болт / гвинт із шестигранною головкою", "parts.family.hex_nut": "Шестигранна гайка", "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", @@ -1741,6 +1836,8 @@ "prefs.anti_aliasing_note": "Згладжує нерівні краї.", "prefs.applies_on_restart": "Застосовується після перезапуску", "prefs.assistant": "Асистент", + "prefs.auto_operation": "Автоматичний режим операцій", + "prefs.auto_operation_note": "Автоматично виконувати команди без додаткових запитів там, де це безпечно", "prefs.axes": "Осі", "prefs.axis": "Вісь", "prefs.bodies_folders": "Теки тіл", @@ -1751,6 +1848,11 @@ "prefs.bottom_centre": "Внизу по центру", "prefs.bottom_right": "Внизу праворуч", "prefs.browser": "Браузер", + "prefs.browser_eye_left": "Око поруч зі стрілкою згортання", + "prefs.browser_eye_left_note": "Рядки браузера показують око «показати/приховати» поруч зі стрілкою згортання, як і раніше, а не в стовпці на правому краї рядків.", + "prefs.browser_reveal_on_hover": "Показувати в браузері при наведенні", + "prefs.browser_show_note": "Налаштування відображення браузера проєкту", + "prefs.browser_shown": "Показувати браузер", "prefs.built_in": "Вбудована", "prefs.button_left": "Перетягування лівою кнопкою", "prefs.button_middle": "Перетягування середньою кнопкою", @@ -1798,6 +1900,8 @@ "prefs.graphics_card_restart": "Набуває чинності після перезапуску SolidCraft. Змінна WGPU_ADAPTER_NAME перевизначає цей вибір.", "prefs.graphics_note": "Обміняйте швидкість на плавність. Кожен варіант показує свій ефект на зразку.", "prefs.graphics_presets_note": "Набори задають наведені нижче параметри разом.", + "prefs.hide_constraint_glyphs_zoomed_out": "Ховати позначки обмежень на віддаленому виді", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Позначки обмежень зникають, коли ескіз віддалено настільки, що вони б захаращували вид. Вимкнено: вони зберігають сталий розмір на екрані за будь-якого масштабу.", "prefs.history": "Історія", "prefs.history_origin_bodies": "Показувати початок координат і тіла вгорі", "prefs.history_origin_bodies_note": "Історія ліворуч або під браузером починається з теки «Тіла» активного компонента та початку координат, з їхніми значками ока й меню, як у браузері. Історія внизу не змінюється.", @@ -1821,6 +1925,8 @@ "prefs.layout_note": "«Компактне» прикріплює панелі до країв вікна. Прозорість браузера доступна в режимі «Плаваюче». Сторону Асистента можна змінити на сторінці «Асистент».", "prefs.left": "Ліворуч", "prefs.left_click": "Клацання лівою", + "prefs.line_drag_draws_one": "Перетягування відрізка малює один елемент", + "prefs.line_drag_draws_one_note": "Замість безперервного малювання при перетягуванні", "prefs.linked_designs": "Пов'язані проєкти", "prefs.live_input": "Поточне введення", "prefs.live_preview": "Попередній перегляд у реальному часі", @@ -1834,6 +1940,9 @@ "prefs.max_file_size_step": "Файли STEP та імпорту", "prefs.max_file_size_web": "Вебзастосунок відкриває файли STEP та імпорту до 512 МБ і проєкти до 256 МБ: вкладка браузера не вміщує більші файли. У настільному застосунку ці межі можна збільшити.", "prefs.megabytes": "МБ", + "prefs.menu_grid": "Великі піктограми", + "prefs.menu_list": "Список", + "prefs.menus": "Меню", "prefs.minutes": "хв", "prefs.minutes_suffix": " хв", "prefs.mouse": "Миша й трекпад", @@ -1853,14 +1962,42 @@ "prefs.origin_plane_names": "Назви площин початку координат", "prefs.origin_plane_names_note": "Назви, що показуються в браузері, діалогах і списках. {top} — це XY, {front} — XZ, {right} — YZ, а Z спрямована вгору. Команди, скрипти й збережені проєкти зберігають XY, XZ і YZ.", "prefs.overall_speed": "Загальна швидкість", + "prefs.persistent_ribbon_pressed": "Підсвічувати активні команди стрічки", + "prefs.persistent_ribbon_pressed_help": "Підсвічувати команди стрічки, поки їхні ефекти ввімкнені. Вимкніть, щоб зберегти попередній нейтральний вигляд і довідку команд.", + "prefs.perspective_fov": "Кут огляду перспективи", "prefs.placement": "Розміщення", "prefs.prefer_free_sketch_points": "Надавати перевагу рухомим точкам ескізу, коли точки перекриваються", "prefs.preview_choices": "Перегляд ваших виборів", + "prefs.print_face_highlight": "Підсвічування грані друку", + "prefs.print_face_highlight_tip": "Наскільки сильно грань друку тіла зафарбовується й обводиться у вигляді. 0 % — без підсвічування.", + "prefs.profile_active": "Активний профіль", + "prefs.profile_applied": "Профіль застосовано.", + "prefs.profile_changed": "Налаштування змінено після збереження профілю. «Зберегти» додасть їх до профілю.", + "prefs.profile_deleted": "Профіль видалено.", + "prefs.profile_export": "Експортувати…", + "prefs.profile_exported": "Профіль експортовано.", + "prefs.profile_imported": "Профіль імпортовано. Виберіть його в полі «Активний профіль», щоб перейти на нього.", + "prefs.profile_name_hint": "Назва профілю, напр. Ноутбук", + "prefs.profile_rename": "Перейменувати", + "prefs.profile_renamed": "Профіль перейменовано.", + "prefs.profile_save": "Зберегти", + "prefs.profile_saved": "Поточні налаштування збережено в профілі.", + "prefs.profile_skipped": "Профіль застосовано; налаштувань, що зберегли поточне значення: {n} (див. журнал).", + "prefs.profiles": "Профілі налаштувань", + "prefs.profiles_note": "Комбінації клавіш, панель інструментів, навігація, кругове меню та налаштування інтерфейсу, що зберігаються разом під однією назвою й перемикаються одночасно.", + "prefs.project_options_dialog": "Діалог параметрів проєкту", + "prefs.project_options_dialog_note": "Відкривати діалог параметрів проєкту замість бічної панелі", "prefs.quality_default": "типово", "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": "Максимальний обсяг пам'яті для кешу геометрії попереднього стану", "prefs.reports_saved": "Збережено: {n}", + "prefs.ribbon_reveal": "Показувати результати пошуку на стрічці", + "prefs.ribbon_reveal_note": "Наведення курсора на результат у «Пошуку команд» показує, де команда розташована на стрічці: її вкладку, кнопку або рядок у випадному меню панелі. Команда, запущена з пошуку, блимає там, а запущена команда з випадного меню позначає свою панель.", "prefs.right_click": "Клацання правою", "prefs.saving": "Автозбереження й відновлення", "prefs.scale_note": "Також Ctrl + / Ctrl − у вікні. Кнопки мають 28 px за 100%.", @@ -1869,21 +2006,34 @@ "prefs.search_empty": "Немає відповідних налаштувань", "prefs.select": "Вибрати", "prefs.select_one_item": "Вибрати один елемент", + "prefs.select_other_hidden": "Вибирати приховану геометрію через «Вибрати інше»", + "prefs.select_other_hidden_help": "Увімкнено: до списку входять приховані тіла, ескізи та затулені ребра поблизу вказівника. Вимкнено: використовується початковий список видимої геометрії під вказівником.", "prefs.selecting": "Вибір", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift лише додає; Ctrl / Cmd перемикає", "prefs.shift_add_note": "Увімкнено: Shift додає, Ctrl / Cmd перемикає. Вимкнено (типово): обидва перемикають.", "prefs.shortcuts": "Комбінації клавіш", + "prefs.single_surface_analysis": "Аналіз окремої поверхні", + "prefs.single_surface_analysis_help": "Виконувати аналіз лише для вибраної поверхні", + "prefs.sketch_align_points": "Вирівнювати точки ескізу", + "prefs.sketch_align_points_note": "Автоматично вирівнювати точки при наближенні", "prefs.sketch_conflict_colour": "Конфліктна геометрія кольором конфлікту", "prefs.sketch_conflict_colour_note": "Поки обмеження ескізу суперечать одне одному, криві та точки, які утримує проблемне обмеження, також малюються кольором конфлікту, а не лише його піктограма чи розмір. Вимкнено: вони зберігають свої звичайні кольори.", + "prefs.sketch_construction_lines": "Розширені інструменти допоміжних прямих", "prefs.sketch_exit_view": "Коли ескіз завершується", "prefs.sketch_exit_view.keep": "Зберегти поточний вид", "prefs.sketch_exit_view.previous": "Повернутися до попереднього виду", "prefs.sketch_exit_view.unless_rotated": "Повернутися, якщо вид не оберталося", "prefs.sketch_exit_view_note": "Куди переходить вид після «Завершити ескіз», «Скасувати» або подвійного Esc.", + "prefs.sketch_open_look_at": "Повертати вид до ескізу під час його відкриття", + "prefs.sketch_open_look_at_note": "Вимкнено: створення чи редагування ескізу не змінює вид. «Наведення» повертає вид до ескізу, коли ви цього захочете.", + "prefs.sketch_recent_snap": "Автоприв'язка до нещодавніх центрів і середніх точок", + "prefs.sketch_recent_snap_note": "Під час креслення курсор прив'язується до місця, де перетинаються допоміжні лінії двох останніх центрів кіл або середніх точок прямих відрізків. Вимкнено: ці лінії лише показуються, а курсор не зсувається.", "prefs.sketch_selection": "Ескіз і вибір", "prefs.sketch_selection_heading": "Ескіз і вибір", "prefs.sketch_selection_note": "Як ескізи прив'язуються й як клацання вибирають.", + "prefs.sketch_state_icons": "Піктограми стану ескізу", + "prefs.sketch_state_icons_note": "Показувати стан обмежень та ступенів вільності біля точок", "prefs.sketch_tab_always": "Завжди показувати вкладку «Ескіз»", "prefs.sketch_tab_always_note": "Вкладка «Ескіз» залишається на стрічці одразу після вкладки «Модель», поки жоден ескіз не відкрито. Її інструменти креслення тоді запускають ескіз на вибраній площині чи грані або на площині, яку ви виберете далі, з уже готовим інструментом.", "prefs.sketch_tracking_guides": "Допоміжні лінії вирівнювання за центром і середньою точкою", @@ -1893,6 +2043,11 @@ "prefs.source": "Джерело", "prefs.speed": "Швидкість", "prefs.stand_up_note": "Коли файл STEP або IGES відкрито з віссю Z угору, пропонувати поворот на чверть оберту навколо X.", + "prefs.stand_up_unknown": "Вирівнювання файлів з невідомою орієнтацією", + "prefs.stand_up_unknown.ask": "Запитувати", + "prefs.stand_up_unknown.note": "Визначати орієнтацію для файлів із невідомим напрямком осей", + "prefs.stand_up_unknown.off": "Не вирівнювати", + "prefs.stand_up_unknown_note": "Що робити, якщо напрямок осей файлу невідомий (Y вгору чи Z вгору)", "prefs.status_bar": "Рядок стану", "prefs.status_bar_note": "Тонкий рядок уздовж нижнього краю вікна показує, що саме редагується, стан відкритого ескіза (його кольором) та одиниці проєкту; клацання по одиницях змінює їх. Тоді стан ескіза більше не показується в куті виду.", "prefs.status_done": "Готово", @@ -1918,7 +2073,14 @@ "prefs.toast_place": "Сповіщення", "prefs.toggle_item": "Додати або прибрати елемент", "prefs.toolbar": "Панель інструментів", + "prefs.toolbar_tabs": "Вкладки панелі інструментів", "prefs.top": "Верх", + "prefs.trackpad_zoom_modifier": "Масштабування прокруткою двома пальцями з утриманням", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "Утримуйте лише цю клавішу, щоб масштабувати в точці курсора вертикальною прокруткою. Вимкнено — поточна поведінка прокрутки зберігається.", + "prefs.trackpad_zoom_modifier.off": "Вимк.", "prefs.translucent": "Напівпрозорий", "prefs.under_browser": "Під браузером", "prefs.under_cursor": "Під курсором", @@ -1928,13 +2090,26 @@ "prefs.view_confirm_corner": "OK і Скасувати в куті виду", "prefs.view_confirm_corner_note": "Поки відкрито діалог або ескіз, галочка й хрестик у правому верхньому куті виду роблять те саме, що OK і Скасувати в діалозі, або завершують і скасовують ескіз. Навігатор виду зсувається нижче.", "prefs.viewport": "Вікно перегляду", + "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.wheel": "Колесо", "prefs.wheel_trackpad": "Колесо й трекпад", "prefs.workspace": "Робоча область", + "prefs.zoom_stop_at_model": "Зупиняти масштаб на моделі", + "prefs.zoom_stop_at_model_tip": "3D-миша та застосунок пристрою масштабують до поверхні в центрі виду; масштабування перетягуванням утримує точку початку. Вимкнено: масштаб може проходити крізь модель. Колесо й стискання окремо використовують «Масштаб до курсора»", "prefs.zoom_toward_cursor": "Масштаб до курсора", "properties.title": "Властивості: {name}", "recovery.days_ago": "{count} дн. тому", "recovery.detail": "{file} · елементів: {features} · автозбережено {when}", + "recovery.discard_all": "Відхилити все", + "recovery.discard_all_detail": "Цю дію не можна скасувати.", + "recovery.discard_all_title": "Відхилити автозбережень: {count}?", + "recovery.discard_all_title_one": "Відхилити 1 автозбереження?", "recovery.hours_ago": "{count} год тому", "recovery.just_now": "щойно", "recovery.min_ago": "{count} хв тому", @@ -2019,6 +2194,7 @@ "render.appearance_editor.weight": "Вага", "render.appearance_physics_separate": "Візуальний вигляд не задає густину, теплові дані чи інші інженерні властивості.", "render.appearances": "Вигляди", + "render.assign_help": "Клацніть, щоб застосувати до виділення; перетягніть для попереднього перегляду й відпустіть або клацніть без виділення, щоб нанести.", "render.availability_native": "Вбудована композиція; перегляньте перед застосуванням", "render.availability_projection": "Підтримувана проєкція; див. обсяг і пропуски", "render.availability_reference": "Довідкові дані; не автоматичне призначення", @@ -2057,6 +2233,7 @@ "render.bloom_threshold": "Лінійний поріг відблисків", "render.browse_capabilities": "Переглянути повний каталог можливостей", "render.browse_environments": "Переглянути оригінальні середовища HDR", + "render.browse_looks": "Огляд…", "render.browse_native_scenes": "Переглянути вбудовані студії", "render.browse_physical": "Переглянути фізичні еталони", "render.browse_quality": "Переглянути специфікації якості", @@ -2080,6 +2257,82 @@ "render.check_opaque": "Прозорість: лише непрозорі поверхні; альфа-канал фону підтримується.", "render.check_sorted_alpha": "Прозорість: наближення з сортуванням за альфою.", "render.check_transmission": "Прозорість: фізичне пропускання світла.", + "render.children.absolute_floor": "Абсолютний поріг", + "render.children.adaptive": "Адаптивна вибірка", + "render.children.add_look_reason": "Додайте стиль, щоб змінити його ввімкнення та силу.", + "render.children.all_shots": "Усі кадри", + "render.children.bloom": "Світіння", + "render.children.check": "Перевірити", + "render.children.check_now": "Перевірити зараз", + "render.children.chromatic": "Хроматичне", + "render.children.compact": "Компактний", + "render.children.contrast": "Контраст", + "render.children.copy_image": "Копіювати", + "render.children.copy_job": "Копіювати JSON завдання", + "render.children.copy_request": "Копіювати JSON запиту", + "render.children.coverage_error": "Похибка покриття", + "render.children.cpu": "Трасування променів на CPU", + "render.children.denoise": "Зменшення шуму", + "render.children.depth": "Глибина шляху", + "render.children.distortion": "Спотворення", + "render.children.edge_samples": "Вибірки країв", + "render.children.effects_banner": "Ці ефекти потребують трасування шляхів на GPU. Відкриття цього ніколи не змінює метод.", + "render.children.enable_adaptive": "Увімкніть незалежні проби, щоб змінити адаптивні межі.", + "render.children.enable_bloom": "Увімкніть свічення, щоб змінити його силу, поріг і радіус.", + "render.children.finishing": "Оздоблення", + "render.children.flare": "Відблиск", + "render.children.fresnel": "Шари Френеля", + "render.children.ghosts": "Кільця-привиди", + "render.children.glass": "Перенесення світла у склі", + "render.children.go_to": "Перейти до {editor}", + "render.children.grain": "Зерно", + "render.children.halo": "Гало", + "render.children.hdr": "HDR", + "render.children.keep_master": "Зберігати необроблений лінійний майстер", + "render.children.last_result": "Останній: {number} · {width} × {height} · {phase}", + "render.children.lens_effects": "Ефекти об'єктива", + "render.children.look_summary": "{tone} · стилів: {count}", + "render.children.looks": "Стилі вигляду", + "render.children.memory": "Пам'ять", + "render.children.method": "Метод", + "render.children.minimum": "Мінімум", + "render.children.needs_cpu": "Потрібне трасування променів на CPU. Відкриття цього ніколи не змінює метод.", + "render.children.nested": "Сертифіковані вкладені діелектрики", + "render.children.not_checked": "Не перевірено", + "render.children.path": "Трасування шляхів", + "render.children.pilots": "Пробні вибірки", + "render.children.preview": "Дані попереднього перегляду", + "render.children.progressive_reason": "Виберіть «Поступове», щоб змінити кількість вибірок і початкове значення.", + "render.children.radius": "Радіус", + "render.children.reflect_depth": "Глибина відбиття", + "render.children.reflect_samples": "Вибірки відбиття", + "render.children.regrade": "Повторне градуювання", + "render.children.relative_error": "Відносна похибка", + "render.children.render": "Рендеринг", + "render.children.render_shot": "Відрендерити кадр", + "render.children.renderer": "Рендерер", + "render.children.renderers": "Рендерери", + "render.children.request": "Цей запит", + "render.children.reset_finishing": "Скинути оздоблення", + "render.children.review_quality": "Переглянути специфікацію якості…", + "render.children.rgb_gain": "Підсилення RGB", + "render.children.samples": "Вибірки", + "render.children.sampling": "Дискретизація", + "render.children.saturation": "Насиченість", + "render.children.scene_summary": "Тіл: {bodies} · трикутників: {triangles}", + "render.children.seed": "Початкове значення", + "render.children.sharpening": "Підвищення різкості", + "render.children.single_image": "Одне зображення", + "render.children.soft_focus": "М'який фокус", + "render.children.streak": "Смуга", + "render.children.strength": "Сила", + "render.children.studio": "Студія", + "render.children.threshold": "Поріг", + "render.children.tone": "Тон", + "render.children.under_hood": "Технічні подробиці", + "render.children.vignette": "Віньєтування", + "render.children.workstation": "Робоча станція", + "render.choose_look": "Спершу виберіть стиль у розділі «Вигляд»", "render.chromatic_offset": "Хроматичне зміщення країв", "render.clear_stage": "Видалити збережену сцену", "render.copy_request": "Копіювати JSON", @@ -2331,8 +2584,33 @@ "render.flare_strength": "Сила відблиску яскравих місць", "render.flare_threshold": "Поріг відблиску в лінійній сцені", "render.fold_browser": "Згорнути браузер", + "render.frame_aspect": "Співвідношення сторін", + "render.frame_centre_mark": "Центральна позначка", + "render.frame_copy_limit": "Немає вільної назви для копії кадру", + "render.frame_copy_name": "{name} — копія {number}", + "render.frame_custom": "Власне", + "render.frame_distance_unit": " × розмір", + "render.frame_duplicate": "Дублювати", + "render.frame_exposure": "Експозиція", + "render.frame_fov": "Поле зору", + "render.frame_guides": "Напрямні", + "render.frame_invalid_aspect": "Недійсний набір співвідношення сторін", + "render.frame_lens": "FOV {fov}° · ≈ {mm} мм", + "render.frame_name": "Назва", + "render.frame_output_size": "Розмір виводу", + "render.frame_photo": "3:2 · Фото", + "render.frame_portrait": "4:5 · Портрет", "render.frame_presets": "Набори кадрування", "render.frame_presets_pending": "З'явиться разом із редактором кадрів", + "render.frame_projection": "Проєкція", + "render.frame_render_shot": "Відрендерити цей кадр", + "render.frame_rule_thirds": "Правило третин", + "render.frame_safe_area": "Безпечна зона", + "render.frame_shot_title": "Кадр · {name}", + "render.frame_square": "1:1 · Квадрат", + "render.frame_tall": "9:16 · Вертикальний", + "render.frame_thirds": "Третини", + "render.frame_wide": "16:9 · Широкоекранний", "render.gpu_depth": "Максимальна глибина шляху", "render.gpu_options_help": "Вибірки, глибина, початкове значення та експозиція зберігаються разом із цим набором налаштувань. Зберігається лише активний метод зображення.", "render.gpu_path_failed": "Трасувальник шляхів GPU недоступний", @@ -2360,6 +2638,7 @@ "render.heading_results": "РЕЗУЛЬТАТИ", "render.heading_shots": "КАДРИ", "render.hide_shots_label": "Сховати стрічку кадрів", + "render.hover_look": "{object} · {look} · клацніть, щоб вибрати", "render.image_advanced": "Додаткові параметри GPU", "render.image_cancelled": "Рендеринг скасовано", "render.image_cancelling": "Скасування", @@ -2506,17 +2785,95 @@ "render.library_uniform_ack": "Використати однорідне наближення без просторових чи розширених ефектів", "render.library_usable": "Виконувані проєкції", "render.library_users": "Використовують", + "render.light_aim": "Спрямувати на", "render.light_anchor_pending": "Зміна системи координат світла поки не підтримується.", + "render.light_azimuth": "Азимут", + "render.light_backend_limit": "Виберіть трасування променів на CPU або явну студію GPU, щоб редагувати освітлення. Застарілі джерела світла студії GPU залишаються доступними лише для читання.", + "render.light_behaviour": "ПОВЕДІНКА", + "render.light_default_fill": "Заповнювальне", + "render.light_default_key": "Основне", + "render.light_default_number": "Джерело світла {number}", + "render.light_default_rim": "Контурне", + "render.light_delete": "Видалити", + "render.light_distance": "Відстань", + "render.light_duplicate": "Дублювати", + "render.light_elevation": "Кут піднесення", + "render.light_gpu_punctual": "Точкові й прожекторні джерела світла доступні з трасуванням променів на CPU.", + "render.light_hard_shadow": "Це джерело світла має жорсткі тіні; м'якість випромінювача недоступна.", + "render.light_intensity": "Інтенсивність", + "render.light_missing": "Цього джерела світла більше немає. Виберіть джерело світла в браузері.", + "render.light_place_highlight": "Розмістити за підсвічуванням", + "render.light_place_pending": "Розміщення за підсвічуванням потребує вибору джерел світла у вікні перегляду й поки не підтримується.", + "render.light_position": "ПОЛОЖЕННЯ", + "render.light_preview_help": "Живий вид без тіней; площинні джерела світла використовують наближення за центральною вибіркою. Рендеринг використовує повну модель освітлення.", + "render.light_product_centre": "Центр виробу", + "render.light_size": "Розмір", + "render.light_softness": "М'якість", + "render.light_temperature": "Колірна температура", + "render.light_temperature_help": "Наближений відтінок абсолютно чорного тіла; зберігається як лінійний RGB.", + "render.light_type": "Тип", + "render.light_type_area": "Площинне", + "render.light_type_point": "Точкове", + "render.light_type_spot": "Прожектор", + "render.light_type_sun": "Сонце", + "render.light_view_only": "«Лише це джерело» та заглушення впливають на цей вид і його рендери; вони ніколи не зберігаються в проєкті.", + "render.light_world_origin": "Світовий початок координат", + "render.lighting_aim_point": "Точка наведення", + "render.lighting_apply": "Застосувати шаблон", + "render.lighting_area_samples": "Вибірки площинного світла", + "render.lighting_budget": "Бюджет", + "render.lighting_budget_help": "Лічильники доступні лише для читання. Додавайте, дублюйте або видаляйте джерела світла, щоб змінити їх; метод задає межі.", + "render.lighting_colour": "Колір", + "render.lighting_consent": "Замінити спрямовані джерела світла цим шаблоном", + "render.lighting_custom": "Власне освітлення", + "render.lighting_custom_help": "Вибір «Власне» перетворює шаблон на редаговані джерела світла, по одному рядку в дереві.", + "render.lighting_direction": "Напрямок X Y Z", + "render.lighting_direction_help": "Або введіть напрямок до джерела світла (X Y Z).", + "render.lighting_distance_unit": " × розмір", + "render.lighting_edge_u": "Вектор сторони U", + "render.lighting_edge_v": "Вектор сторони V", + "render.lighting_falloff": "Спад", + "render.lighting_falloff_help": "Інтенсивність задано на еталонній відстані.", + "render.lighting_height": "Висота V", + "render.lighting_minimum": "Мінімум", + "render.lighting_mute": "Заглушити це джерело світла", + "render.lighting_point": "Точкове", + "render.lighting_preset": "Шаблон освітлення", + "render.lighting_preset_cpu": "Спрямовані студійні шаблони потребують трасування променів на CPU. Освітлення GPU використовує явні джерела «Сонце» та «Площинне».", + "render.lighting_preset_row": "Шаблон", + "render.lighting_radiance": "Яскравість", + "render.lighting_reference": "Еталонна відстань", + "render.lighting_review_help": "Замінює список спрямованих джерел світла. Зберігає точкові та площинні джерела, середовище, тіні, камеру й усі інші налаштування кадру.", + "render.lighting_review_title": "Переглянути шаблон освітлення", + "render.lighting_samples_cpu": "Вибірки площинного світла застосовуються до трасування променів на CPU.", + "render.lighting_samples_need_area": "Додайте площинне джерело світла CPU, щоб задати вибірки площинного світла.", + "render.lighting_samples_reason": "Вибірки площинного світла мають бути 1, 4, 16 або 64.", + "render.lighting_shadow_cpu": "Перемикач тіней потребує трасування променів на CPU. Трасування шляхів на GPU завжди відкидає тіні.", + "render.lighting_shadows": "Відкидати тіні", + "render.lighting_shadows_section": "Тіні та дискретизація", + "render.lighting_shape": "Форма", + "render.lighting_solo": "Лише це джерело світла", + "render.lighting_source": "Джерело", + "render.lighting_source_section": "Джерело світла", + "render.lighting_spot_preserved": "Це точкове джерело світла зберігає раніше заданий конус прожектора, доки не змінено його тип.", + "render.lighting_sun_distance": "Напрямок сонця не залежить від відстані та точки наведення.", + "render.lighting_unsupported": "не підтримується", + "render.lighting_used": "{used} з {limit}", + "render.lighting_width": "Ширина U", + "render.lighting_world_position": "Світові X Y Z", "render.lights_fixed_to": "Світло прив'язане до", "render.linear_help": "Зберігає 32-бітний float-майстер RGBA до шумозаглушення, експозиції, тональної компресії, фільтрів і тла. Лінійний sRGB/D65; попередньо помножений на покриття. Додаткова пам'ять допускається явно. Без стиснення та додаткових проходів.", "render.linear_retain": "Зберегти необроблений лінійний EXR", "render.linear_unavailable": "У вибраного завершеного кадру немає збереженого EXR. Увімкніть збереження перед рендерингом; старіший PNG не може відновити необроблену яскравість.", "render.look_add": "Додати колірний стиль", + "render.look_applied": "Застосовано: {name}", "render.look_bleach": "У дусі bleach bypass", "render.look_cool": "Холодний", "render.look_cyanotype": "У дусі ціанотипії", "render.look_faded": "Вицвілий відбиток", + "render.look_material": "Матеріал", "render.look_mono": "Монохромний", + "render.look_pasted": "Стиль вставлено", "render.look_sepia": "Сепія", "render.look_strength": "Сила стилю", "render.look_teal_orange": "Бірюзовий / помаранчевий", @@ -2592,9 +2949,11 @@ "render.output_advanced": "Додатково", "render.output_backend_ready": "Готово", "render.output_check": "Перевірити", + "render.output_check_failed": "Перевірка · помилка", "render.output_check_help": "Натисніть «Перевірити», щоб переглянути запит без запуску рендерингу.", "render.output_check_summary": "Перевірка · Примітки: {count}", "render.output_copy": "Копіювати", + "render.output_copy_report": "Копіювати звіт", "render.output_destination": "Файл призначення", "render.output_destination_help": "Виберіть файл призначення під час експорту готового зображення.", "render.output_dimensions": "Розмір", @@ -2604,6 +2963,7 @@ "render.output_fit": "Вписати", "render.output_format": "Формат", "render.output_format_status": "PNG — це точне завершене зображення для показу. Необов'язковий GPU EXR зберігає необроблений сцено-лінійний радіанс FLOAT32 до фільтрів відображення. Експорт ніколи не підставляє кеш попереднього перегляду чи інше завдання.", + "render.output_grading": "Градуювання", "render.output_image": "Зображення", "render.output_largest": "макс. {width} × {height}", "render.output_lock_ratio": "Зафіксувати співвідношення сторін", @@ -2613,6 +2973,8 @@ "render.output_quality_waiting": "очікування контракту якості", "render.output_ratio_refused": "Пов'язаний розмір перевищує обмеження цього методу. Попередній розмір збережено; розблокуйте співвідношення або виберіть менше значення.", "render.output_ready": "Готово до рендерингу", + "render.output_run_check": "Запустити перевірку", + "render.output_size_limit": "{method}: до {dimension} px на сторону та {pixels} пікселів. Найбільший придатний розмір: {width} × {height}.", "render.output_size_ok": "Розмір допустимий · {width} × {height} px", "render.output_swap_orientation": "Поміняти місцями ширину та висоту", "render.output_tools_inspector": "Змініть розмір і формат в інспекторі «Вивід».", @@ -2629,6 +2991,7 @@ "render.page_output": "Вивід", "render.page_scene": "Сцена", "render.paint_label": "Нанести", + "render.painting_with": "Нанесення: {name}", "render.passive_stage": "Земля та пасивна сцена", "render.passive_stage_help": "Скінченні пасивні панелі існують лише на відрендерених зображеннях. Вони не додають тіл CAD чи маси. Розташування фіксується після застосування; перетин із межами виробу відхиляється. CPU не забезпечує дифузного відбитого освітлення.", "render.perspective": "Перспективна камера", @@ -2650,6 +3013,7 @@ "render.pick_help": "Виберіть тіло, грань або компонент. Клацніть на порожньому місці, щоб скасувати вибір; Shift або Ctrl додає чи перемикає. Підсвічування видно лише в живому виді виробу.", "render.pick_label": "Вибір", "render.pick_up_label": "Взяти", + "render.pick_up_prompt": "Клацніть об'єкт, щоб узяти його стиль", "render.pick_waiting": "Вибір у виді виробу поки не підтримується.", "render.post_contrast": "Контраст", "render.post_gain_b": "Підсилення синього", @@ -2689,13 +3053,17 @@ "render.radial_distortion": "Радіальне спотворення", "render.radial_fit": "Вписати виріб", "render.radial_save": "Зберегти кадр", + "render.recent_looks": "Недавні стилі", "render.regrade_action": "Повторно градуювати необроблений майстер (без шумозаглушення)", "render.regrade_help": "Створює окремий PNG із вибраного завершеного необробленого майстра за цими параметрами подачі. Не трасує промені, не змінює зафіксований розмір, камеру чи світло та не перезаписує EXR. Шумозаглушення не застосовується повторно: необроблені майстри не зберігають його допоміжних буферів. Похідне зображення можна експортувати тими самими елементами керування завданням.", "render.regrade_label": "Корекція", "render.reload_settings": "Перезавантажити збережені налаштування", + "render.remove_override": "Прибрати перевизначення", + "render.replace_all": "Замінити все", "render.replace_ground": "Підтвердити заміну наявної сцени", "render.replace_label": "Замінити", "render.replace_saved_surface": "Замінити наявну збережену прив'язку для цієї цілі", + "render.replace_with": "Замінити на", "render.restore_camera": "Відновити збережену камеру", "render.result_actual": "100%", "render.result_actual_help": "Один піксель зображення на піксель екрана.", @@ -2747,14 +3115,28 @@ "render.saved_surface_remove": "Прибрати збережену прив'язку", "render.saved_surfaces": "Збережені прив'язки поверхонь", "render.saved_surfaces_help": "Закріплені вбудовані карти слідують за вибраним екземпляром у локальних міліметрах. Замінена або відсутня ціль — це помилка, а не автоматичне перепризначення.", + "render.scene_all_shots": "Усі кадри ({count})", "render.scene_apply": "Застосувати переглянуту студію", + "render.scene_batch_reason": "Для «Усіх кадрів» потрібна черга рендерингу збережених кадрів, якої ще немає.", + "render.scene_check_now": "Перевірити зараз", + "render.scene_check_passed": "Перевірку пройдено · приміток: {notes}", + "render.scene_check_stale": "Перевірка застаріла · перевірте ще раз", + "render.scene_check_unchecked": "Ще не перевірено · приміток: 0", "render.scene_consent": "Підтвердьте заміну освітлення, середовища, експозиції та майданчика", + "render.scene_light_summary": "{environment} · джерел світла: {lights}", + "render.scene_look_summary": "Стилів: {looks} · перевизначень: {overrides}", + "render.scene_open_check": "Відкрити перевірку", "render.scene_preserved_fields": "Камера, вибірка, розміри, оптичні прив'язки, CAD-геометрія та інженерні матеріали зберігаються.", "render.scene_preserves_design": "Складає освітлення та пасивний майданчик для видимого об'єкта. Геометрія, інженерні матеріали й камера залишаються окремими.", + "render.scene_ready": "Готово до рендерингу", + "render.scene_reminder": "Сцена зберігає проєкт. Геометрія, інженерні властивості та CAD-дані ніколи не змінюються з рендерингу.", + "render.scene_reminders": "Нагадування", + "render.scene_renderer_ready": "{renderer} готовий", "render.scene_replaces": "Замінює", "render.scene_review": "Визначити й переглянути", "render.scene_review_help": "Перегляньте повну вбудовану композицію перед застосуванням. Непов'язані розширені специфікації SCENE не підставляються.", "render.scene_scope_details": "Обсяг композиції", + "render.scene_stages": "Етапи", "render.search_appearances": "Пошук серед виглядів: {count}", "render.setup_legacy_environment": "Це старіше налаштування використовує застарілі обертання чи інтенсивність середовища. Ці значення зберігаються; явно перетворіть їх перед збереженням повного завдання зображення.", "render.setup_name": "Назва налаштування", @@ -2767,7 +3149,22 @@ "render.shell_tab_camera": "КАМЕРА РЕНДЕРИНГУ", "render.shell_tab_lighting": "ОСВІТЛЕННЯ", "render.shell_tab_scene": "СЦЕНА РЕНДЕРИНГУ", + "render.shot_aperture": "Діафрагма", + "render.shot_distance": "Відстань", + "render.shot_dof": "Глибина різкості", "render.shot_duplicate": "Дублювати збережений кадр", + "render.shot_edit_output": "Редагувати у виводі", + "render.shot_exposure_reason": "Експозиція потребує трасування променів на CPU або трасування шляхів на GPU.", + "render.shot_exposure_section": "Експозиція", + "render.shot_focus_distance": "Відстань фокусування", + "render.shot_fov_reason": "Поле зору потребує перспективної проєкції.", + "render.shot_frame_subject": "Кадрувати об'єкт", + "render.shot_invalid_camera": "Значення камери мають бути скінченними й у межах діапазону.", + "render.shot_lens": "Об'єктив", + "render.shot_lens_enable_reason": "Увімкніть глибину різкості, щоб змінити діафрагму й відстань фокусування.", + "render.shot_lens_gpu_reason": "Глибина різкості потребує трасування шляхів на GPU.", + "render.shot_lens_perspective_reason": "Глибина різкості потребує перспективної проєкції.", + "render.shot_look_selection": "Спрямувати на виділення", "render.shot_manager": "Збережені кадри", "render.shot_new_name": "Назва нового кадру", "render.shot_remove": "Прибрати збережений кадр", @@ -2775,6 +3172,9 @@ "render.shot_remove_title": "Прибрати збережений кадр?", "render.shot_rename": "Перейменувати збережений кадр", "render.shot_saved_only": "Спершу збережіть поточну чернетку. Дублювання та перейменування зберігають точні збережені параметри й посилання на ресурси.", + "render.shot_selection_reason": "Виберіть тіло або грань, на яку спрямувати.", + "render.shot_size": "Розмір", + "render.shot_target": "Ціль", "render.shots_camera_changed": "Камера змінилася після збереження цього кадру", "render.shots_name_help": "Введіть унікальну назву поточного кадру.", "render.shots_no_result": "Рендерингу ще немає", @@ -2997,6 +3397,13 @@ "render.wait_pick_up": "ще не підтримується: вибір у виді виробу та взяття зразка вигляду", "render.wait_replace": "ще не підтримується: заміна всіх використань вигляду", "render.wait_solo": "ще не підтримується: видимість світла та режим «лише це світло»", + "render.wear_applies_to": "Застосовується до", + "render.wear_layer_count": "{count} шарів", + "render.wear_layer_one": "{count} шар", + "render.wear_layers": "Шари", + "render.wear_no_layers": "Немає шарів", + "render.wear_selected_layer": "Вибраний шар", + "render.wear_title": "Старіння", "sample.bracket.about": "Основна полиця з кромковими полицями та загином за правилом 2 mm", "sample.bracket.name": "Кронштейн з листового металу", "sample.enclosure.about": "Оболонковий корпус із буртиком, бобишками під гвинти, упором і клацковим з'єднанням", @@ -3030,6 +3437,7 @@ "select an axis": "виберіть вісь", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "спершу виберіть вісь, пряме або кругове ребро чи грань циліндра, конуса або тора", "select bodies first": "спершу виберіть тіла", + "select components first": "Спочатку виберіть компоненти", "select corner points, or the two lines of one corner": "виберіть кутові точки або дві лінії одного кута", "select one corner point, or the two lines of one corner": "виберіть одну кутову точку або дві лінії одного кута", "select sheet edges first": "спершу виберіть ребра листа", @@ -3048,6 +3456,7 @@ "settings.double_click_components": "Двічі клацніть компонент, щоб активувати його або відредагувати пов'язаний компонент на місці", "settings.frame_rate_details_note": "Додає графік часу останніх кадрів із накладеними поверх нього трикутниками, гістограму часу кадрів і те, що намалював GPU. Клацніть частоту кадрів на виді, щоб показати чи сховати їх.", "settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду.", + "settings.insert_lists_open_designs": "Вставлення проєкту спершу показує проєкти, відкриті в інших вкладках", "settings.measure_axis_distances": "Відстані X/Y/Z", "settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.", "settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft", @@ -3072,6 +3481,10 @@ "shortcut.unpin": "Відкріпити", "sketch is missing": "ескіз відсутній", "sketch.degrees_of_freedom": "{count} ступенів вільності", + "sketch.dimension_added_driven": "Ескіз уже визначає цей розмір, тому його додано як опорний (ведений) розмір", + "sketch.dimension_applied": "Розмір застосовано", + "sketch.dimension_applied_defined": "Розмір застосовано; визначено параметр «{name}»", + "sketch.dimension_applied_renamed": "Розмір застосовано; параметр перейменовано на «{name}»", "sketch.dof_left": "Залишилося ступенів свободи: {count}", "sketch.dof_left_one": "Залишився {count} ступінь свободи", "sketch.draw_curves": "Малювати як {linetype}", @@ -3106,6 +3519,8 @@ "sketch_text.use_font_file": "Використати файл шрифту {path}", "solid": "суцільне", "status.editing": "Редагування: {name}", + "status.navigation": "Призначення миші: {mapping}", + "status.navigation_tip": "Як миша рухає вид. Клацніть, щоб вибрати інше призначення", "status.under_constrained": "Недостатньо обмежено (ступенів вільності: {count})", "status.under_constrained_one": "Недостатньо обмежено (ступенів вільності: {count})", "status.units_tip": "Одиниці проєкту. Клацніть, щоб змінити їх", @@ -3139,16 +3554,20 @@ "timeline.delete_folders_tip": "Зберегти їхні операції та вкладені теки в історії", "timeline.delete_folders_with_features_tip": "Видалити теки з усіма операціями в них і вкладеними теками", "timeline.delete_many": "Видалити вибрані операції: {count}", + "timeline.edited_rolled_back": "{name} змінено, але воно після маркера історії, тому не побудовано: перемістіть маркер далі, щоб побачити результат", "timeline.feature_error": "Помилка: {message}", "timeline.feature_hint": "Двічі клацніть, щоб змінити · перетягніть, щоб змінити порядок · права кнопка миші — більше", + "timeline.feature_rolled_back": "Відкочено: після маркера історії, тому не побудовано", "timeline.feature_warning": "Попередження: {message}", "timeline.group": "Нова тека операцій", "timeline.move_many": "Перемістити елементи часового ланцюжка", "timeline.move_to_group": "Перемістити операції до теки", "timeline.rename_group": "Перейменувати теку операцій", "timeline.reorder_many": "Перевпорядкувати операції", + "timeline.suppress_features": "Придушити операції", "timeline.suppress_folders_tip": "Придушити або скасувати придушення всіх операцій у теках і вкладених теках", "timeline.ungroup": "Прибрати теки операцій", + "timeline.unsuppress_features": "Скасувати придушення операцій", "to the face": "до грані", "to the next face": "до наступної грані", "toolbar.customize_note": "Вкладка {tab}. Позначте команду, щоб додати її кнопку на панель інструментів. Також можна перетягнути рядок зі спадного меню панелі на панель інструментів, перетягнути кнопку за межі панелі для видалення або скористатися значком закріплення в рядку спадного меню.", @@ -3220,6 +3639,10 @@ "ui.3d_mouse_offer": "Калібрування 3D-миші виправляє переплутані або обернені осі та налаштовує швидкість і відчуття.", "ui.3d_mouse_offer_never": "Більше не показувати", "ui.3d_mouse_offer_swap": "Перемкнути на Hidraw (прямий драйвер)", + "ui.3d_mouse_orbit": "Обертання", + "ui.3d_mouse_orbit_classic": "Класичне", + "ui.3d_mouse_orbit_tip": "Трасоване: 3D-миша обертає вид навколо точки на моделі в центрі екрану. Класичне: навколо центру виду", + "ui.3d_mouse_orbit_traced": "Трасоване", "ui.3d_mouse_reading": "Показники", "ui.3d_mouse_reset_axes": "Скинути призначення осей", "ui.3d_mouse_reset_view": "Скинути вигляд", @@ -3256,6 +3679,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.", "ui.3d_mouse_switch_cap": "Калібрувати як повернення до центру", "ui.3d_mouse_switch_trackball": "Калібрувати як трекбол", + "ui.3d_mouse_use_direct": "Використати прямий драйвер", + "ui.3d_mouse_use_direct_tip": "Читати 3D-мишу напряму (hidraw), а не через spacenavd.", "ui.3d_mouse_view_axis": "Вісь вигляду", "ui.3d_mouse_view_off": "Вимк.", "ui.3d_mouse_view_off_tip": "Профіль драйвера 3D-миші вимикає цю вісь, тож її рух не рухає вигляд. Якщо вибрати тут рух іншої осі, вони поміняються місцями, і цей рух перестане рухати вигляд.", @@ -3562,6 +3987,8 @@ "ui.cancel": "Скасувати", "ui.canvases": "Полотна", "ui.center": "Центр", + "ui.center_arc_follows_cursor": "Дуга за центром іде за напрямком курсора", + "ui.center_arc_follows_cursor_tip": "Дуга за центром іде за годинниковою або проти годинникової стрілки відповідно до руху курсора навколо центра. Вимкнено: завжди проти годинникової стрілки від початку.", "ui.centerline": "Осьова лінія", "ui.chain": "Ланцюг", "ui.chamfer": "Фаска", @@ -3570,6 +3997,7 @@ "ui.circle": "Коло", "ui.circular_pattern": "Круговий масив", "ui.clear": "Очистити", + "ui.clear_print_face": "Скинути грань друку", "ui.clear_recent": "Очистити недавні", "ui.clear_the_selection": "Зняти виділення", "ui.click_selects_whole_bodies": "Клацання виділяє тіла цілком", @@ -3604,6 +4032,15 @@ "ui.constant": "Стала", "ui.constraints_conflict": "Конфлікт обмежень", "ui.construction": "Конструкційна", + "ui.construction.angle_hint": "Допоміжна пряма: клацніть точку, потім введіть кут або виберіть напрямок", + "ui.construction.bisector_hint": "Бісектриса: виберіть дві прямі, що перетинаються, потім потрібний варіант", + "ui.construction.common_hint": "Спільна дотична: виберіть два кола/дуги, потім потрібний варіант", + "ui.construction.contact_hint": "Дотична: виберіть коло/дугу або відкритий сплайн/конічну криву, потім точку дотику (криві довільної форми: кінцеві точки)", + "ui.construction.external_hint": "Дотична: виберіть коло або дугу, зовнішню точку, потім потрібний варіант", + "ui.construction.free_hint": "Допоміжна пряма: клацніть дві точки; клацніть правою кнопкою, щоб почати знову", + "ui.construction.point_hint": "Допоміжна пряма: клацніть точку, через яку вона проходить", + "ui.construction.reference_hint": "Допоміжна пряма: виберіть пряму, потім точку; клацніть правою кнопкою, щоб змінити опорний об'єкт", + "ui.construction.ribbon_label": "Доп. лінії", "ui.context_menu": "Контекстне меню", "ui.continuity_g0": "G0 злам", "ui.continuity_g1": "G1 дотичність", @@ -3695,6 +4132,14 @@ "ui.end_scale": "Масштаб у кінці", "ui.entities": "Елементи", "ui.equal": "Рівність", + "ui.equation_end": "Кінець", + "ui.equation_explicit": "Явна", + "ui.equation_form": "Форма", + "ui.equation_note": "Змінна — звичайне число: sin(t) сприймає радіани. Результат без одиниць виміру береться в одиницях проєкту.", + "ui.equation_parametric": "Параметрична", + "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.exportDxf": "Експорт DXF…", @@ -3760,6 +4205,7 @@ "ui.heading_design": "ПРОЄКТ", "ui.heading_draft": "УХИЛ", "ui.heading_edit": "РЕДАГУВАТИ", + "ui.heading_equation_curve": "КРИВА ЗА РІВНЯННЯМ", "ui.heading_extrude": "ВИДАВЛЮВАННЯ", "ui.heading_fillet": "ЗАОКРУГЛЕННЯ", "ui.heading_finish_editing": "ЗАВЕРШЕННЯ РЕДАГУВАННЯ", @@ -3851,6 +4297,10 @@ "ui.import_ellipsis": "Імпорт…", "ui.infinite": "Нескінченна", "ui.insert": "Вставити", + "ui.insert_design.browse": "Огляд…", + "ui.insert_design.open_designs": "Відкриті проєкти", + "ui.insert_design.saved_file": "{name} має незбережені зміни, тому вставлено його збережений файл. Збережіть його, потім оновіть зв'язки, щоб перенести зміни.", + "ui.insert_design.unsaved": "Незбережені зміни", "ui.inspect": "Перевірити", "ui.intersect": "Перетин", "ui.isolate": "Ізолювати", @@ -3980,12 +4430,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": "Одиниці файлу", @@ -3994,12 +4449,29 @@ "ui.polygon": "Багатокутник", "ui.preferences": "Налаштування", "ui.press_a_key": "натисніть клавішу…", + "ui.print_face_cleared": "{body}: грань друку скинуто", + "ui.print_face_input": "Грань друку", + "ui.print_face_one_body": "Грань друку належить одному тілу: надішліть це тіло окремо, щоб змінити її", + "ui.print_face_pick": "Виберіть плоску грань твердого тіла, щоб зробити її гранню друку", + "ui.print_face_set": "{body}: грань друку задано; «Надіслати в слайсер» кладе тіло на неї", "ui.profile": "Профіль", "ui.profile_plane": "Площина профілю", "ui.profiles": "Профілі", "ui.project": "Проєктувати", "ui.project_face_edges_sketch": "Проєктувати ребра граней у нові ескізи", "ui.properties": "Властивості", + "ui.properties.copy_matrix": "Копіювати матрицю", + "ui.properties.first_moments": "Перші моменти", + "ui.properties.inertia": "Моменти інерції", + "ui.properties.inertia_note": "Відносно центра мас, у світових осях (г·{unit}²)", + "ui.properties.inertia_unavailable": "Недоступно: сітка тіла не замкнена.", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "Вимірювання…", + "ui.properties.measuring_failed": "Вимірювання перервано до завершення.", + "ui.properties.principal_axis": "Головна вісь {number}", + "ui.properties.principal_moments": "Головні моменти", "ui.pull_direction": "Напрямок витягування", "ui.quality": "Якість", "ui.quantity": "Кількість", @@ -4045,6 +4517,7 @@ "ui.revolve": "Обертання", "ui.ribbon_exit": "Вихід", "ui.ribbon_labels": "Показувати текст під піктограмами стрічки", + "ui.ribbon_menus_skip_pinned": "Не показувати закріплені команди у випадних меню", "ui.ribbon_pin_footer": "Закріплені команди відображаються на стрічці. Наведіть курсор на рядок, щоб закріпити або відкріпити його.", "ui.ribbon_workspace": "Робоча область", "ui.right": "Справа", @@ -4099,7 +4572,16 @@ "ui.sketch": "Ескіз", "ui.sketch_dimension": "Розмір ескізу", "ui.sketch_grid": "Сітка ескізу", + "ui.sketch_intersect_geometry": "Геометрія", + "ui.sketch_intersect_projection_link": "Проєкційний зв'язок", + "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": "Розріз", @@ -4150,6 +4632,7 @@ "ui.tab_manage": "Керування", "ui.tab_sheet_metal": "Листовий Метал", "ui.tab_view": "Вид", + "ui.tab_weldments": "Зварювання", "ui.tangent": "Дотична", "ui.taper_angle": "Кут звуження", "ui.tapped": "З різьбою", @@ -4165,6 +4648,7 @@ "ui.tip_angle": "Кут вістря", "ui.to_object": "До об'єкта", "ui.tool_direction": "Напрямок інструмента", + "ui.toolbar_tabs_hidden": "Усі вкладки панелі інструментів приховано. Клацніть + або клацніть тут правою кнопкою, щоб показати одну.", "ui.top": "Зверху", "ui.top_diameter": "Верхній діаметр", "ui.top_radius": "Верхній радіус", @@ -4202,15 +4686,21 @@ "ui.view_centre": "Центр виду", "ui.viewport_status_clear": "Вимкнути {name}", "ui.viewport_status_clear_hint": "Вимкнути {name} · Esc", + "ui.viewport_status_edges": "Ребер: {count}", + "ui.viewport_status_from": "з напрямку: {direction}", "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_theme_follow": "За темою", "ui.visual_shaded_hidden_edges": "Затінений із прихованими ребрами", "ui.visual_shaded_with_edges": "Затінений із ребрами", "ui.visual_style": "Стиль відображення", "ui.visual_wireframe": "Каркас", + "ui.wheel_zoom_speed": "Швидкість масштабування колесом миші", "ui.width": "Ширина", "ui.window_system": "Віконна система", "ui.window_system_auto": "Автоматично", @@ -4241,7 +4731,9 @@ "update.button": "Оновити", "update.button_version": "Оновити до {version}", "update.check_automatically": "Автоматично перевіряти оновлення", + "update.check_for_updates": "Перевірити оновлення", "update.check_now": "Перевірити зараз", + "update.check_unavailable": "Перевірка оновлень недоступна для цього встановлення.", "update.checking": "Перевірка…", "update.copy": "Копіювати", "update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть SolidCraft до Applications, замінивши стару копію, а потім перезапустіть SolidCraft.", @@ -4255,13 +4747,18 @@ "update.installed": "SolidCraft {version} установлено. Перезапустіть, щоб скористатися.", "update.installer_started": "Інсталятор запуститься, коли SolidCraft закриється.", "update.later": "Нагадати пізніше", + "update.managed": "Доступне оновлення: оновіть SolidCraft у {manager}", "update.no_notes": "Цей випуск не має приміток.", "update.package_hint": "Ця копія походить з {kind}. Завантажте новий пакет, а потім установіть його за допомогою:", "update.prefs_dev": "Це збірка для розробки ({version}): вона не перевіряє оновлення.", "update.prefs_flatpak": "Flatpak оновлює цю копію ({version}): виконайте {command} або скористайтеся центром програм. SolidCraft не перевіряє.", "update.prefs_label": "Оновлення", + "update.prefs_managed": "Це SolidCraft {version}, яким керує {manager}. Перевірка запитує в GitHub найновіший випуск і більше нічого не надсилає; оновіть SolidCraft у {manager}.", "update.prefs_note": "Це SolidCraft {version} ({kind}). Перевірка запитує в GitHub останній випуск і більше нічого не надсилає.", "update.prefs_web": "Перезавантажте сторінку, щоб отримати останню веб-версію застосунку. Якщо мережа недоступна або оновлення не вдається завантажити вчасно, відкривається збережена версія; завантаження, що триває, продовжується до наступного перезавантаження.", + "update.refresh_checking": "Перевірка найновішого випуску…", + "update.refresh_fallback": "Не вдалося перевірити новіший випуск; оновлення до {version}, знайденої раніше.", + "update.refresh_selected": "Оновлення до {version}.", "update.release_page": "Відкрити сторінку випуску", "update.restart": "Перезапустити зараз", "update.restart_later": "Пізніше", diff --git a/assets/locales/vi.json b/assets/locales/vi.json index fb53c9868a..5a7cfc72a6 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "MẶT NỐI MƯỢT", "BOUNDARY SURFACE": "BỀ MẶT BIÊN DẠNG", "BROWSER": "CÂY ĐỐI TƯỢNG", + "Back to the original order": "Trở về thứ tự ban đầu", "Ball": "Cầu", "Bar": "Thép thanh", "Base Center": "Tâm đáy", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Bắt vào tâm mặt và tâm cạnh", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft không thể gửi báo cáo trực tiếp. Hãy gửi trên GitHub; ứng dụng không cần mã truy cập.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft đã đóng trước khi các thiết kế này được lưu. Bạn có thể mở bản tự động lưu gần nhất của chúng.", + "Sort A to Z": "Sắp xếp từ A đến Z", + "Sort Z to A": "Sắp xếp từ Z đến A", "Spacing 2 must be a non-zero number": "Khoảng cách 2 phải là một số khác 0", "Spacing must be a non-zero number": "Khoảng cách phải là một số khác 0", "Specialise": "Chuyên biệt hóa", @@ -775,6 +778,8 @@ "at the face": "tại bề mặt", "bom.group_children": "Chi tiết con trong hàng của chi tiết cha", "browser.body_name": "Thân khối{number}", + "browser.calibrate_click_a": "Nhấp vào ảnh để chọn điểm A, hoặc nhập tọa độ của điểm đó.", + "browser.calibrate_click_b": "Nhấp vào ảnh để chọn điểm B, hoặc nhập tọa độ của điểm đó.", "browser.calibrate_title": "Hiệu chuẩn: {name}", "browser.capture_many": "{count} chi tiết đã được di chuyển nhưng vị trí chưa được ghi lại.", "browser.capture_note": "Ghi vị trí sẽ giữ vị trí mới; Hoàn nguyên sẽ đặt lại như cũ.", @@ -817,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.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ả", "cmd.browser_expand_all": "Mở rộng tất cả", @@ -1101,6 +1107,16 @@ "cmd.sheet_refold": "Gập lại tấm kim loại", "cmd.sheet_rules": "Danh sách quy tắc kim loại tấm", "cmd.sheet_unfold": "Trải phẳng tạm thời", + "cmd.sketch.construction_line": "Đường thẳng dựng hình", + "cmd.sketch.construction_line.angle": "Đường dựng hình theo góc", + "cmd.sketch.construction_line.bisector": "Đường phân giác", + "cmd.sketch.construction_line.horizontal": "Đường dựng hình nằm ngang", + "cmd.sketch.construction_line.parallel": "Đường dựng hình song song", + "cmd.sketch.construction_line.perpendicular": "Đường dựng hình vuông góc", + "cmd.sketch.construction_line.tangent_at": "Tiếp tuyến tại điểm trên đường cong", + "cmd.sketch.construction_line.tangent_external": "Tiếp tuyến qua điểm ngoài", + "cmd.sketch.construction_line.tangent_two": "Tiếp tuyến chung", + "cmd.sketch.construction_line.vertical": "Đường dựng hình thẳng đứng", "cmd.sketch_arc3d": "Cung tròn 3D", "cmd.sketch_arc_center_point": "Cung tròn xác định bởi tâm và điểm", "cmd.sketch_arc_tangent": "Cung tròn tiếp tuyến", @@ -1389,7 +1405,18 @@ "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.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.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.", + "help.sketch.construction_line.horizontal": "Tạo đường dựng hình nằm ngang vô hạn qua một điểm. Ràng buộc ngang được giữ khi chỉnh sửa sau này.", + "help.sketch.construction_line.parallel": "Chọn đường thẳng trong phác thảo rồi đặt đường dựng hình vô hạn song song. Nhấp chuột phải để đổi tham chiếu.", + "help.sketch.construction_line.perpendicular": "Chọn đường thẳng trong phác thảo rồi đặt đường dựng hình vô hạn vuông góc. Nhấp chuột phải để đổi tham chiếu.", + "help.sketch.construction_line.tangent_at": "Tạo tiếp tuyến tại một điểm trên đường tròn hoặc cung tròn, hoặc tại đầu mút của spline hay đường conic hở. Chọn đường cong rồi chọn tiếp điểm.", + "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.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.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.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.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.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.", @@ -1592,6 +1619,10 @@ "material.selection.mixed": "Các vật liệu khác nhau", "measure.at": "Tại", "measure.item": "Mục {number}", + "measure.quick_copied": "Đã sao chép số đo", + "measure.quick_copy": "Nhấp để sao chép số đo", + "measure.quick_edges": "{count} cạnh", + "measure.quick_faces": "{count} mặt", "messages.ago_h": "{n} giờ trước", "messages.ago_min": "{n} phút trước", "messages.ago_s": "{n} giây trước", @@ -1744,6 +1775,11 @@ "not a move": "không phải một phép di chuyển", "not a number": "không phải là số", "open a sketch first": "hãy mở một phác thảo trước", + "parameters.apply_live": "Áp dụng mỗi thay đổi khi rời khỏi ô của nó", + "parameters.apply_live_tip": "Tắt: các thay đổi chờ đến khi nhấn Áp dụng hoặc OK, rồi thiết kế được dựng lại một lần cho tất cả. Hủy hoặc Escape sẽ bỏ chúng.", + "parameters.held": "{count} thay đổi đang chờ Áp dụng", + "parameters.held_hint": "Sửa biểu thức và đơn vị. Thay đổi chờ đến khi nhấn Áp dụng hoặc OK.", + "parameters.held_one": "1 thay đổi đang chờ Áp dụng", "parts.automatic_name": "tự động", "parts.dimension.across_flats": "Cỡ cờ lê", "parts.dimension.bore": "Đường kính lỗ", @@ -1766,6 +1802,8 @@ "parts.family.set_screw": "Vít trí lục giác chìm, đầu bằng", "parts.family.socket_head_cap_screw": "Bu lông lục giác chìm đầu trụ", "parts.family.washer": "Vòng đệm phẳng", + "parts.holes": "Lỗ (tùy chọn)", + "parts.placement_hint": "chọn một hoặc nhiều lỗ để lắp một chi tiết vào mỗi lỗ, hoặc nhấn OK để đặt tại gốc tọa độ", "pattern.alignment_2": "Căn chỉnh 2", "pattern.alignment_hint": "Số lượng bao gồm cả bản gốc. Căn giữa sẽ cân bằng các bản sao; Đổi hướng sẽ chọn phía bổ sung cho tổng số chẵn.", "pattern.direction_1": "Hướng 1", @@ -1818,6 +1856,8 @@ "prefs.bottom_centre": "Giữa phía dưới", "prefs.bottom_right": "Góc dưới bên phải", "prefs.browser": "Cây thư mục", + "prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn", + "prefs.browser_eye_left_note": "Các hàng trong cây thư mục hiển thị biểu tượng mắt bật/tắt cạnh mũi tên thu gọn như trước đây, thay vì trong một cột ở đầu bên phải của các hàng.", "prefs.browser_reveal_on_hover": "Hiện khi di chuột", "prefs.browser_show_note": "Hiển thị giữ trình duyệt trên màn hình. Hiện khi di chuột ẩn nó cho đến khi con trỏ đến vị trí của nó hoặc thẻ mảnh ở cạnh trái, và nó vẫn mở khi con trỏ ở bên trong hoặc một menu của nó đang mở.", "prefs.browser_shown": "Hiển thị", @@ -1868,6 +1908,8 @@ "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_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ỏ", + "prefs.hide_constraint_glyphs_zoomed_out_note": "Biểu tượng ràng buộc biến mất khi bản phác được thu nhỏ đủ xa để không làm rối khung nhìn. Tắt: chúng giữ kích thước màn hình cố định ở mọi mức thu phóng.", "prefs.history": "Lịch sử", "prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng", "prefs.history_origin_bodies_note": "Cây Lịch sử ở bên trái hoặc dưới cây thư mục sẽ bắt đầu với thư mục Thân khối và Gốc tọa độ của cụm chi tiết đang kích hoạt, cùng biểu tượng mắt bật/tắt và trình đơn tương tự như trong cây thư mục. Dòng Lịch sử ở cạnh dưới không thay đổi.", @@ -1906,6 +1948,9 @@ "prefs.max_file_size_step": "Tệp STEP và tệp nhập", "prefs.max_file_size_web": "Ứng dụng web mở tệp STEP và tệp nhập tối đa 512 MB, thiết kế tối đa 256 MB: thẻ trình duyệt không chứa được tệp lớn hơn. Ứng dụng máy tính cho phép tăng các giới hạn này.", "prefs.megabytes": "MB", + "prefs.menu_grid": "Biểu tượng lớn", + "prefs.menu_list": "Danh sách", + "prefs.menus": "Menu", "prefs.minutes": "phút", "prefs.minutes_suffix": " phút", "prefs.mouse": "Chuột và bàn di cảm ứng", @@ -1925,10 +1970,16 @@ "prefs.origin_plane_names": "Tên các mặt phẳng gốc", "prefs.origin_plane_names_note": "Tên hiển thị trong trình duyệt, hộp thoại và danh sách. {top} là XY, {front} là XZ và {right} là YZ, với Z hướng lên. Lệnh, script và thiết kế đã lưu vẫn dùng XY, XZ và YZ.", "prefs.overall_speed": "Tốc độ tổng thể", + "prefs.parameter_list_rows": "Số dòng danh sách tham số", + "prefs.parameter_list_rows_tip": "Phím mũi tên xuống trong ô giá trị trống liệt kê các tham số, mục yêu thích trước, tối đa số này. Ít hơn giúp danh sách nhẹ trong thiết kế lớn.", + "prefs.persistent_ribbon_pressed": "Tô sáng lệnh đang bật trên dải băng", + "prefs.persistent_ribbon_pressed_help": "Tô sáng các lệnh trên dải băng khi hiệu ứng của chúng đang bật. Tắt tùy chọn này để giữ giao diện trung tính và trợ giúp lệnh như trước.", "prefs.perspective_fov": "Trường nhìn", "prefs.placement": "Vị trí đặt", "prefs.prefer_free_sketch_points": "Ưu tiên điểm phác thảo có thể di chuyển khi các điểm chồng lên nhau", "prefs.preview_choices": "Xem trước các lựa chọn của bạn", + "prefs.print_face_highlight": "Tô sáng mặt in", + "prefs.print_face_highlight_tip": "Mức tô màu và viền mặt in của thân trong khung nhìn. 0 % không tô sáng.", "prefs.profile_active": "Hồ sơ đang dùng", "prefs.profile_applied": "Đã áp dụng hồ sơ.", "prefs.profile_changed": "Cài đặt đã thay đổi kể từ khi lưu hồ sơ. “Lưu” sẽ ghi chúng vào hồ sơ.", @@ -1950,6 +2001,8 @@ "prefs.quality_fastest": "nhanh nhất", "prefs.quality_slowest": "chậm nhất (chất lượng cao nhất)", "prefs.quality_smoother": "mượt mà hơn", + "prefs.quick_measure": "Đo nhanh ở góc khung nhìn", + "prefs.quick_measure_note": "Hiển thị kích thước của đối tượng được chọn ở góc dưới bên phải khung nhìn, phía trên Lịch sử: chiều dài của cạnh (hoặc Ø và R với cạnh tròn), diện tích của mặt, vị trí của điểm, khoảng cách và góc giữa hai đối tượng, tổng của nhiều cạnh hoặc mặt. Nhấp để sao chép.", "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}", @@ -1963,6 +2016,8 @@ "prefs.search_empty": "Không có cài đặt nào khớp", "prefs.select": "Chọn", "prefs.select_one_item": "Chọn một mục", + "prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác", + "prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.", "prefs.selecting": "Đang chọn", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn", @@ -1974,11 +2029,14 @@ "prefs.sketch_align_points_note": "Khi vẽ, một điểm được căn ngang theo một điểm phác thảo và căn dọc theo điểm khác cùng lúc, kèm đường dẫn chấm; cú nhấp giữ cả hai ràng buộc căn. Tắt: chỉ căn theo điểm trước, mỗi lần một trục.", "prefs.sketch_conflict_colour": "Hình học xung đột theo màu xung đột", "prefs.sketch_conflict_colour_note": "Khi các ràng buộc của bản phác xung đột, các đường cong và điểm mà một ràng buộc lỗi giữ cũng được vẽ bằng màu xung đột, không chỉ ký hiệu hay kích thước của nó. Tắt: chúng giữ màu thường.", + "prefs.sketch_construction_lines": "Công cụ đường dựng hình mở rộng", "prefs.sketch_exit_view": "Khi kết thúc phác thảo", "prefs.sketch_exit_view.keep": "Giữ góc nhìn hiện tại", "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_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", "prefs.sketch_recent_snap_note": "Khi vẽ, con trỏ được bắt vào nơi các đường dẫn của hai tâm đường tròn hoặc trung điểm cạnh thẳng gần nhất cắt nhau. Tắt: các đường dẫn chỉ để xem và con trỏ không di chuyển.", "prefs.sketch_selection": "Phác thảo & chọn đối tượng", @@ -2038,13 +2096,21 @@ "prefs.under_cursor": "Dưới con trỏ chuột", "prefs.units_precision": "Đơn vị và độ chính xác", "prefs.updates": "Cập nhật ứng dụng", + "prefs.value_boxes_in_view": "Hiện giá trị cạnh tay nắm trong khung nhìn", + "prefs.value_boxes_in_view_tip": "Giá trị của lệnh (Khoảng cách, Bán kính…) trong một ô cạnh tay nắm, phía trên mô hình. Tắt: bạn nhập giá trị trong cửa sổ lệnh, cửa sổ này nhận bàn phím sau khi chọn, và không có gì che mô hình.", "prefs.view": "Hành vi góc nhìn", "prefs.view_confirm_corner": "Nút Đồng ý và Hủy bỏ ở góc khung nhìn", "prefs.view_confirm_corner_note": "Khi hộp thoại hoặc môi trường phác thảo đang mở, dấu tích và dấu chéo ở góc trên bên phải khung nhìn có chức năng tương tự nút Đồng ý và Hủy bỏ của hộp thoại, hoặc để hoàn tất và hủy phác thảo. Khối điều hướng góc nhìn sẽ dời xuống bên dưới chúng.", "prefs.viewport": "Khung nhìn 3D", + "prefs.viewport_analysis_status": "Hiện trạng thái phân tích trong khung nhìn", + "prefs.viewport_analysis_status_help": "Hiện các phân tích đang bật phía trên khung nhìn và mở chú giải từ từng mục. Esc tắt phân tích mới nhất. Tắt tùy chọn để khôi phục chú giải ở góc và hành vi Esc trước đây.", "prefs.viewport_background": "Nền", "prefs.viewport_background_colour": "Màu", "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_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", "prefs.wheel_trackpad": "Con lăn chuột và bàn di cảm ứng", "prefs.workspace": "Không gian làm việc", @@ -2054,6 +2120,10 @@ "properties.title": "Thuộc tính: {name}", "recovery.days_ago": "{count} ngày trước", "recovery.detail": "{file} · {features} tính năng · tự động lưu {when}", + "recovery.discard_all": "Hủy bỏ tất cả", + "recovery.discard_all_detail": "Không thể hoàn tác thao tác này.", + "recovery.discard_all_title": "Hủy bỏ {count} bản tự động lưu?", + "recovery.discard_all_title_one": "Hủy bỏ 1 bản tự động lưu?", "recovery.hours_ago": "{count} giờ trước", "recovery.just_now": "vừa xong", "recovery.min_ago": "{count} phút trước", @@ -2919,7 +2989,9 @@ "render.output_ready": "Sẵn sàng kết xuất", "render.output_run_check": "Chạy kiểm tra", "render.output_size_limit": "{method}: tối đa {dimension} px mỗi cạnh và {pixels} điểm ảnh. Kích thước lớn nhất vừa khít: {width} × {height}.", + "render.output_size_method_profile": "Để kết xuất {width} × {height}, hãy chọn {method} với cấu hình tài nguyên CPU {profile} hoặc một kích thước nhỏ hơn.", "render.output_size_ok": "Kích thước ổn · {width} × {height} px", + "render.output_size_profile": "Để kết xuất {width} × {height}, hãy chọn cấu hình tài nguyên CPU {profile} hoặc một kích thước nhỏ hơn.", "render.output_swap_orientation": "Hoán đổi chiều rộng và chiều cao", "render.output_tools_inspector": "Chỉnh kích thước và định dạng trong bảng thuộc tính Đầu ra.", "render.output_view_image": "Xem ảnh", @@ -3132,6 +3204,7 @@ "render.size_custom": "Tùy chỉnh…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · cần {profile}", "render.size_presets": "Cỡ đặt sẵn", "render.size_presets_pending": "Sẽ có cùng trình chỉnh Đầu ra", "render.size_preview": "Xem trước", @@ -3419,6 +3492,24 @@ "sheet.flange_type_contour": "Đường viền", "sheet.flange_type_edge": "Cạnh", "sheet.no_body": "chưa có khối tấm kim loại: hãy bắt đầu bằng Cánh gập > Cơ sở (Base), hoặc chuyển một tấm phẳng thành tấm kim loại bằng lệnh 'Chuyển thành tấm kim loại' (chọn mặt phẳng lớn của nó)", + "sheet.personal_rules.confirm_delete": "Xác nhận xóa", + "sheet.personal_rules.delete": "Xóa", + "sheet.personal_rules.delete_prompt": "Xóa quy tắc cá nhân này? Các quy tắc đã có trong thiết kế sẽ không thay đổi.", + "sheet.personal_rules.full": "Thư viện cá nhân đã chứa tối đa 64 quy tắc.", + "sheet.personal_rules.invalid_name": "Nhập một tên ngắn gọn, không chứa ký tự xuống dòng hoặc ký tự điều khiển.", + "sheet.personal_rules.invalid_values": "Hãy điền đủ các giá trị của quy tắc: mỗi giá trị phải tính được trong thiết kế này, với độ dày dương và hệ số K từ 0 đến 1.", + "sheet.personal_rules.missing": "Quy tắc cá nhân đã chọn không còn khả dụng.", + "sheet.personal_rules.name": "Tên quy tắc", + "sheet.personal_rules.name_exists": "Quy tắc cá nhân với tên này đã tồn tại. Vui lòng sử dụng tên khác.", + "sheet.personal_rules.name_hint": "Tên duy nhất", + "sheet.personal_rules.overwrite": "Lưu các thay đổi vào quy tắc cá nhân đã chọn", + "sheet.personal_rules.rename": "Đổi tên", + "sheet.personal_rules.rule": "Quy tắc cá nhân", + "sheet.personal_rules.save": "Lưu mới", + "sheet.personal_rules.scope": "Được lưu trong hồ sơ cá nhân của bạn cho tất cả thiết kế.", + "sheet.personal_rules.select": "Chọn một quy tắc", + "sheet.personal_rules.title": "Quy tắc cá nhân", + "sheet.personal_rules.unsupported": "Quy tắc này có các giá trị mà phiên bản hiện tại không thể sử dụng.", "shortcut.json_error": "tham số phải là JSON: {error}", "shortcut.no_command": "không có lệnh `{id}`; văn bản trước JSON phải là mã lệnh", "shortcut.pin": "Ghim lên đầu hộp S", @@ -3455,6 +3546,7 @@ "sketch_text.align_right": "Phải", "sketch_text.bold": "In đậm", "sketch_text.builtin_font": "{family} (tích hợp sẵn)", + "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.on_path": "Văn bản đi theo đường dẫn: đường cong định vị văn bản", @@ -3595,6 +3687,8 @@ "ui.3d_mouse_section": "Chuột 3D", "ui.3d_mouse_show_in_view": "Hiện chuột 3D trong khung nhìn 3D", "ui.3d_mouse_show_in_view_tip": "Hình chuột 3D của bạn ở góc dưới bên trái, chuyển động theo bạn.", + "ui.3d_mouse_sketch_orbit": "Xoay bản phác thảo bằng chuột 3D", + "ui.3d_mouse_sketch_orbit_tip": "Chuột 3D cũng xoay khung nhìn khi bản phác thảo đang mở và được nhìn thẳng, như chuột thường. Tắt: chỉ di chuyển và thu phóng bản phác thảo cho đến khi bạn nghiêng khung nhìn.", "ui.3d_mouse_skip": "Thiết bị của tôi không làm được điều này", "ui.3d_mouse_skip_tip": "Bỏ qua chuyển động này theo cả hai chiều; việc hiệu chuẩn sẽ không dùng nó.", "ui.3d_mouse_speed": "Tốc độ chuột 3D", @@ -3623,6 +3717,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "Xoay núm điều khiển ngược chiều kim đồng hồ, giữ nguyên, rồi thả ra.", "ui.3d_mouse_switch_cap": "Hiệu chuẩn theo kiểu trở về giữa", "ui.3d_mouse_switch_trackball": "Hiệu chuẩn như bi xoay", + "ui.3d_mouse_use_direct": "Dùng trình điều khiển trực tiếp", + "ui.3d_mouse_use_direct_tip": "Đọc chuột 3D trực tiếp (hidraw) thay vì qua spacenavd.", "ui.3d_mouse_view_axis": "Trục khung nhìn", "ui.3d_mouse_view_off": "Tắt", "ui.3d_mouse_view_off_tip": "Hồ sơ của trình điều khiển chuột 3D tắt trục này, nên chuyển động của nó không làm khung nhìn di chuyển. Chọn chuyển động của trục khác ở đây sẽ hoán đổi hai trục, và chuyển động đó không còn di chuyển khung nhìn.", @@ -3756,6 +3852,8 @@ "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)", + "ui.agent_chat_reply_cut_off": "Câu trả lời đã chạm giới hạn độ dài trước khi xong nên có thể chưa đầy đủ. Hãy yêu cầu AI Agent tiếp tục, hoặc trả lời ngắn hơn.", + "ui.agent_chat_reply_declined": "Mô hình đã từ chối trả lời tin nhắn này.", "ui.agent_chat_retry": "Thử lại", "ui.agent_chat_right": "Phải (Hình chiếu cạnh)", "ui.agent_chat_save": "Lưu", @@ -3939,6 +4037,7 @@ "ui.circle": "Hình tròn", "ui.circular_pattern": "Mảng tròn quanh trục", "ui.clear": "Xóa", + "ui.clear_print_face": "Xóa mặt in", "ui.clear_recent": "Xóa danh sách gần đây", "ui.clear_the_selection": "Hủy chọn", "ui.click_selects_whole_bodies": "Nhấp chuột chọn toàn bộ thân khối", @@ -3973,6 +4072,15 @@ "ui.constant": "Hằng số", "ui.constraints_conflict": "Xung đột ràng buộc", "ui.construction": "Đường gióng dựng hình", + "ui.construction.angle_hint": "Đường dựng hình: nhấp một điểm rồi nhập góc hoặc chọn hướng", + "ui.construction.bisector_hint": "Phân giác: chọn hai đường thẳng giao nhau rồi chọn nghiệm mong muốn", + "ui.construction.common_hint": "Tiếp tuyến chung: chọn hai đường tròn/cung rồi chọn nghiệm mong muốn", + "ui.construction.contact_hint": "Tiếp tuyến: chọn đường tròn/cung hoặc spline/conic hở rồi chọn tiếp điểm (đường cong tự do: chỉ đầu mút)", + "ui.construction.external_hint": "Tiếp tuyến: chọn đường tròn hoặc cung, điểm ngoài rồi chọn nghiệm mong muốn", + "ui.construction.free_hint": "Đường dựng hình: nhấp hai điểm; nhấp chuột phải để bắt đầu lại", + "ui.construction.point_hint": "Đường dựng hình: nhấp điểm đi qua", + "ui.construction.reference_hint": "Đường dựng hình: chọn đường thẳng rồi chọn điểm; nhấp chuột phải để đổi tham chiếu", + "ui.construction.ribbon_label": "Đường phụ", "ui.context_menu": "Menu ngữ cảnh", "ui.continuity_g0": "Nếp gập G0", "ui.continuity_g1": "G1 tiếp tuyến", @@ -4362,12 +4470,17 @@ "ui.plugins_skipped": "Không thể đọc {count} thư mục.", "ui.plugins_version": "{version}", "ui.point": "Điểm", + "ui.point_cloud_add_filter": "Thêm bộ lọc…", "ui.point_cloud_auto_range": "Phạm vi tự động", "ui.point_cloud_color_by": "Tô màu theo", "ui.point_cloud_file_colors": "Màu trong tệp", + "ui.point_cloud_filter_by": "Lọc theo", "ui.point_cloud_height_high": "Cao nhất", "ui.point_cloud_height_low": "Thấp nhất", + "ui.point_cloud_legend_filter": "Hiện và ẩn trong chú giải ở trên", "ui.point_cloud_no_file_colors": "Tệp không có màu", + "ui.point_cloud_pause": "Tạm dừng", + "ui.point_cloud_play": "Phát", "ui.point_cloud_single_color": "Một màu", "ui.point_cloud_size": "Kích thước điểm", "ui.point_cloud_units": "Đơn vị của tệp", @@ -4376,6 +4489,11 @@ "ui.polygon": "Đa giác", "ui.preferences": "Tùy chọn cài đặt", "ui.press_a_key": "nhấn một phím…", + "ui.print_face_cleared": "{body}: đã xóa mặt in", + "ui.print_face_input": "Mặt in", + "ui.print_face_one_body": "Mặt in được chọn cho một thân: hãy gửi riêng thân đó để thay đổi", + "ui.print_face_pick": "Chọn một mặt phẳng của thân đặc để đặt làm mặt in", + "ui.print_face_set": "{body}: đã đặt mặt in; Gửi đến Slicer sẽ đặt thân lên mặt này", "ui.profile": "Biên dạng", "ui.profile_plane": "Mặt phẳng biên dạng", "ui.profiles": "Các biên dạng", @@ -4439,6 +4557,7 @@ "ui.revolve": "Xoay tròn tạo khối", "ui.ribbon_exit": "Thoát", "ui.ribbon_labels": "Hiển thị văn bản bên dưới biểu tượng dải băng", + "ui.ribbon_menus_skip_pinned": "Không lặp lại các lệnh đã ghim trong menu thả xuống", "ui.ribbon_pin_footer": "Các lệnh đã ghim hiển thị trên ruy-băng. Rê chuột lên một hàng để ghim hoặc bỏ ghim.", "ui.ribbon_workspace": "Không gian làm việc", "ui.right": "Phải (Hình chiếu cạnh)", @@ -4457,6 +4576,7 @@ "ui.selectOther": "Chọn đối tượng khác…", "ui.selectTangentChain": "Chọn chuỗi tiếp tuyến", "ui.select_all": "Chọn tất cả", + "ui.select_components_by_face": "Chọn thành phần (chọn một mặt sẽ chọn thành phần chứa mặt đó)", "ui.selection": "Vùng chọn", "ui.selection_modifiers": "Phím bổ trợ chọn", "ui.selection_shift_add_only": "Shift+nhấp chỉ thêm, không bỏ chọn", @@ -4498,6 +4618,11 @@ "ui.sketch_intersect_selection_filter": "Bộ lọc lựa chọn", "ui.sketch_modifiers": "Phím bổ trợ phác thảo", "ui.sketch_project_geometry": "Hình học", + "ui.sketch_report.all_constrained": "Mọi thứ đã được ràng buộc hoàn toàn.", + "ui.sketch_report.dof": "Bậc tự do", + "ui.sketch_report.loose": "Chưa ràng buộc hoàn toàn", + "ui.sketch_report.status": "Trạng thái", + "ui.sketch_report.title": "Báo cáo phác thảo", "ui.sketch_scale": "Tỷ lệ phác thảo", "ui.sketches": "Các phác thảo", "ui.slice": "Cắt lát", @@ -4602,10 +4727,19 @@ "ui.view_centre": "Tâm góc nhìn", "ui.viewport_status_clear": "Tắt {name}", "ui.viewport_status_clear_hint": "Tắt {name} · Esc", + "ui.viewport_status_edges": "{count} cạnh", + "ui.viewport_status_from": "từ {direction}", "ui.viewport_status_key": "Hiển thị chú giải cho {name}", "ui.viewport_status_minimum_radius": "Bán kính nhỏ nhất", "ui.viewport_status_pairs": "{count} cặp", + "ui.viewport_status_section_hatch": "{setting} · gạch mặt cắt", + "ui.viewport_status_section_plane": "Mặt phẳng hiện tại", + "ui.viewport_status_section_tilted": "nghiêng · {offset}", "ui.viewport_status_stripes": "{count} sọc", + "ui.viewport_status_summary_clear": "Tắt tất cả", + "ui.viewport_status_summary_heading": "Trong khung nhìn này", + "ui.viewport_status_summary_label": "{name} và {count} mục khác", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Theo chủ đề giao diện", "ui.visual_shaded_hidden_edges": "Tô bóng kèm nét khuất", "ui.visual_shaded_with_edges": "Tô bóng kèm hiển thị cạnh", @@ -4642,7 +4776,9 @@ "update.button": "Cập nhật", "update.button_version": "Cập nhật lên {version}", "update.check_automatically": "Tự động kiểm tra bản cập nhật", + "update.check_for_updates": "Kiểm tra bản cập nhật", "update.check_now": "Kiểm tra ngay", + "update.check_unavailable": "Không thể kiểm tra bản cập nhật cho bản cài đặt này.", "update.checking": "Đang kiểm tra…", "update.copy": "Sao chép", "update.dmg_opened": "Ảnh đĩa đã mở và được xác thực: kéo SolidCraft vào thư mục Applications để thay thế bản cũ, sau đó khởi động lại SolidCraft.", diff --git a/assets/locales/zh-TW.json b/assets/locales/zh-TW.json index 13c00cd99e..8bf7f0c3f9 100644 --- a/assets/locales/zh-TW.json +++ b/assets/locales/zh-TW.json @@ -181,6 +181,8 @@ "Designs you open or save appear here.": "您開啟或儲存的設計會顯示在此處。", "Determined by To Object. Choose Angle to edit the angular extent.": "由「到物件」確定。選擇「角度」可編輯角度範圍。", "Diameter": "直徑", + "Did this assembly open the wrong way up?": "此組合件開啟時的朝向是否不對?", + "Did this part open the wrong way up?": "此零件開啟時的朝向是否不對?", "Dimension new shapes": "為新圖形標註尺寸", "Direction": "方向", "Discard": "捨棄", @@ -204,6 +206,7 @@ "Edges": "邊", "Edit": "編輯", "Edit Profile Sketch": "編輯輪廓草圖", + "Edit shortcuts": "編輯快捷鍵", "Enable rule": "啟用規則", "Enabled": "已啟用", "End Radius": "終止半徑", @@ -466,6 +469,7 @@ "Radius": "半徑", "Radius Fit": "半徑擬合", "Ratio": "比率", + "Read-only list: shortcuts can't be changed here.": "唯讀清單:無法在此變更快捷鍵。", "Reattach selected face": "重新附著所選面", "Recover": "復原", "Recover unsaved design?": "復原未儲存的設計?", @@ -599,6 +603,7 @@ "Square": "正方形", "Stand it up": "將其立起", "Stand it up: a quarter turn about X, as one Move feature you can undo.": "將其立起:繞 X 軸旋轉四分之一週,作為一個可復原的移動特徵。", + "Stand this assembly up?": "立起此組合件?", "Stand this part up?": "立起此零件?", "Start angle": "起始角度", "Start bulge": "起始鼓度", @@ -649,6 +654,7 @@ "Third point": "第三點", "This child is shared. Deleting it removes it from every copy of its parent.": "此子項是共享的。刪除它會從其父項的每個副本中移除。", "This child is shared. Position changes apply to every copy of its parent.": "此子項是共享的。位置變更會套用到其父項的每個副本。", + "This component is inside a subassembly placed more than once: aligning it moves it in every placement.": "此元件位於放置了多次的子組合件內:對齊它會使它在每個放置位置中都跟著移動。", "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面無法提供持久附著。請選擇其他面或明確使用固定平面。", "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "此圖片尚未附加到 GitHub。請複製或儲存圖片,再到 GitHub 附加。", "Thread depth": "螺紋深度", @@ -769,6 +775,8 @@ "at the face": "在面上", "bom.group_children": "子零件併入所屬零件的列", "browser.body_name": "實體{number}", + "browser.calibrate_click_a": "按一下影像以指定點 A,或輸入其座標。", + "browser.calibrate_click_b": "按一下影像以指定點 B,或輸入其座標。", "browser.calibrate_title": "校準:{name}", "browser.capture_many": "{count} 個元件已移動,但尚未擷取其位置。", "browser.capture_note": "「擷取位置」保留新位置;「還原位置」還原原先的位置。", @@ -790,6 +798,11 @@ "cam.roller": "滾子", "cam.roller_pick": "滾子(可選)", "cam.tip_pick": "尖端(可選)", + "camera.shot_pitch": "俯仰", + "camera.shot_roll": "滾轉", + "camera.shot_yaw": "偏航", + "canvas.decal_place_hint": "按一下面以放置貼花", + "canvas.place_hint": "按一下平面或面以放置畫布", "choose a profile": "請選取輪廓", "choose the cam's and the follower's joints": "請選取凸輪和從動件的接頭", "choose the joints to couple": "請選取要耦合的接頭", @@ -797,12 +810,16 @@ "click sketch curves": "按一下草圖曲線", "cmd.appearance_assign": "外觀", "cmd.appearance_chain": "外觀鏈", + "cmd.appearance_copy": "複製外觀", + "cmd.appearance_favorite": "收藏外觀", "cmd.appearance_library": "外觀庫", "cmd.appearance_list": "列出外觀", "cmd.appearance_paint": "塗抹外觀", + "cmd.appearance_paste": "貼上外觀", "cmd.appearance_pick_up": "吸取外觀", "cmd.appearance_replace": "取代外觀", "cmd.assembly_bom": "物料清單", + "cmd.body.print_face": "列印底面", "cmd.body_rename": "重新命名實體", "cmd.browser_collapse_all": "全部摺疊", "cmd.browser_expand_all": "全部展開", @@ -810,6 +827,7 @@ "cmd.browser_move": "移動瀏覽器項目", "cmd.browser_order": "排列瀏覽器項目", "cmd.browser_rename_group": "重新命名群組", + "cmd.browser_reparent": "移動到元件", "cmd.browser_ungroup": "解散群組", "cmd.browser_visibility": "顯示/隱藏", "cmd.canvas_calibrate": "校準畫布", @@ -836,7 +854,9 @@ "cmd.component_metadata": "零件屬性", "cmd.component_metadata_set": "變更零件屬性", "cmd.component_move_bodies": "將實體移至元件", + "cmd.component_move_features": "將特徵移至元件", "cmd.component_move_sketches": "將草圖移至元件", + "cmd.component_paste_from_design": "從其他設計貼上", "cmd.component_paste_new": "貼上為新物件", "cmd.component_properties": "元件物理屬性", "cmd.component_relink": "重新連結來源檔案", @@ -973,6 +993,7 @@ "cmd.motion_trace": "運動軌跡", "cmd.motion_trace_edit": "編輯運動軌跡", "cmd.motion_trace_keep": "保留運動軌跡", + "cmd.occurrence_align": "對齊實例", "cmd.occurrence_copy": "貼上(實例)", "cmd.occurrence_drag": "拖曳實例", "cmd.occurrence_ground": "固定", @@ -1030,11 +1051,17 @@ "cmd.render_library_surface_unbind": "移除表面繫結", "cmd.render_library_targets": "尋找彩現資源庫目標", "cmd.render_lights_add": "新增燈光", + "cmd.render_lights_duplicate": "複製彩現燈光", + "cmd.render_lights_list": "列出彩現燈光", + "cmd.render_lights_mute": "靜音燈光", + "cmd.render_lights_remove": "移除彩現燈光", + "cmd.render_lights_set": "設定彩現燈光", "cmd.render_lights_solo": "獨奏燈光", "cmd.render_optics_bind": "儲存光學繫結", "cmd.render_optics_unbind": "移除光學繫結", "cmd.render_output_size": "設定彩現輸出大小", "cmd.render_preflight": "檢查彩現請求", + "cmd.render_preflight_copy": "複製彩現檢查報告", "cmd.render_preset_apply": "套用原生攝影棚", "cmd.render_preset_resolve": "檢閱原生攝影棚", "cmd.render_quality_prepare": "檢閱固定取樣預算", @@ -1057,6 +1084,13 @@ "cmd.render_start": "彩現影像", "cmd.render_status": "彩現進度", "cmd.select_clear": "清除選擇", + "cmd.settings_profile_apply": "套用設定檔", + "cmd.settings_profile_delete": "刪除設定檔", + "cmd.settings_profile_export": "匯出設定檔", + "cmd.settings_profile_import": "匯入設定檔", + "cmd.settings_profile_list": "設定檔", + "cmd.settings_profile_rename": "重新命名設定檔", + "cmd.settings_profile_save": "儲存設定檔", "cmd.sheet_convert": "轉換為鈑金", "cmd.sheet_export_bend_table": "匯出折彎表", "cmd.sheet_export_dxf": "將展開圖匯出為 DXF", @@ -1070,6 +1104,16 @@ "cmd.sheet_refold": "重新折彎", "cmd.sheet_rules": "列出鈑金規則", "cmd.sheet_unfold": "展開", + "cmd.sketch.construction_line": "建構直線", + "cmd.sketch.construction_line.angle": "指定角度建構直線", + "cmd.sketch.construction_line.bisector": "角平分線", + "cmd.sketch.construction_line.horizontal": "水平建構直線", + "cmd.sketch.construction_line.parallel": "平行建構直線", + "cmd.sketch.construction_line.perpendicular": "垂直建構直線", + "cmd.sketch.construction_line.tangent_at": "曲線上點的切線", + "cmd.sketch.construction_line.tangent_external": "通過外部點的切線", + "cmd.sketch.construction_line.tangent_two": "公切線", + "cmd.sketch.construction_line.vertical": "鉛直建構直線", "cmd.sketch_arc3d": "三維圓弧", "cmd.sketch_arc_center_point": "中心點圓弧", "cmd.sketch_arc_tangent": "相切圓弧", @@ -1102,6 +1146,8 @@ "cmd.sketch_drawing_bounds": "圖紙大小", "cmd.sketch_edit": "編輯草圖", "cmd.sketch_edit_text": "編輯文字", + "cmd.sketch_equation_curve": "方程式曲線", + "cmd.sketch_equation_curve_edit": "編輯方程式曲線", "cmd.sketch_export_dxf": "另存為 DXF", "cmd.sketch_finish_auto_constrain": "完成並自動拘束", "cmd.sketch_fit_curves_to_section": "將曲線擬合到網格截面", @@ -1198,6 +1244,7 @@ "cmd.view.render_browser": "彩現瀏覽器", "cmd.view.render_dock": "移動彩現停靠面板", "cmd.view.render_focus": "彩現聚焦檢視", + "cmd.view.status_items": "視埠狀態項目", "cmd.view_ambient_occlusion": "環境光遮蔽", "cmd.view_angle_readout": "檢視角度", "cmd.view_apply": "還原命名檢視", @@ -1219,6 +1266,7 @@ "cmd.view_normal_to": "正視於選取項目", "cmd.view_previous_tab": "上一個索引標籤", "cmd.view_rename": "重新命名命名檢視", + "cmd.view_render_frame": "彩現畫面輔助線", "cmd.view_right": "右視圖", "cmd.view_save": "新建命名檢視", "cmd.view_shots_strip": "鏡頭列", @@ -1229,10 +1277,14 @@ "color.copy": "點擊複製顏色值", "color.edit": "點擊編輯顏色", "color.gamma": "以 0–255 伽馬空間顯示顏色值", + "color.hex": "十六進位", + "color.hex_help": "輸入 #rrggbb 或 #rrggbbaa;六位數會保留不透明度", + "color.hex_hint": "#rrggbb", "color.hue": "色相", "color.linear": "以 0–1 線性空間顯示顏色值", "color.saturation_value": "飽和度和明度", "color.selected": "所選顏色", + "config.column_filter_empty": "沒有符合的欄", "contact.components_count": "{count} 個元件", "contact.enabled_label": "已啟用", "coupling.gear_1": "齒輪 1", @@ -1277,6 +1329,7 @@ "dialog.dimension_insert_hint": "點擊其他驅動尺寸以插入其名稱", "dialog.dimension_name": "尺寸:{name}", "dialog.edit_title": "編輯 {title}", + "dialog.no_shown_body": "沒有可變更的已顯示實體:請顯示一個,或選擇「新建實體」", "dialog.select": "↖ 選擇", "dialog.selected_count": "↖ 已選擇 {count} 個", "dialog.selection_guidance": "選擇輸入項進行修改。點擊已選物件可取消選擇;按住 Ctrl/Cmd 點擊也可切換選擇。檢查預覽後,再按「確定」。", @@ -1346,8 +1399,20 @@ "help.material.assign": "設定所選實體的材質,例如鋼、鋁或 ABS。用於讓質量和重量與實際零件一致。", "help.mit": "MIT 授權", "help.notice": "NOTICE", + "help.occurrence.move": "僅沿接頭允許的自由度移動組合件零件。請選取零件的面,然後拖曳可用的箭頭或圓弧。", "help.parameters.change": "開啟用於驅動設計尺寸的具名數值與運算式表。用於在同一處修改尺寸,讓所有使用該尺寸的特徵隨之更新。", "help.pointcloud.insert": "將 PLY、XYZ 或 PTS 掃描資料作為點雲插入模型旁。以實測資料建立模型時,可作為參考。", + "help.sketch.construction_line": "建立通過兩點的無限長建構直線。可用來規劃草圖佈局,而不會分割輪廓。", + "help.sketch.construction_line.angle": "建立與草圖 X 軸夾指定角度的無限長建構直線。請選取點並輸入角度,或選取其方向。", + "help.sketch.construction_line.bisector": "選取兩條相交的草圖直線,再選擇其中一條角平分線。結果會跟隨其來源直線。", + "help.sketch.construction_line.horizontal": "建立通過某一點的水平無限長建構直線。其水平拘束會在後續編輯中持續維持。", + "help.sketch.construction_line.parallel": "選取一條草圖直線,再放置與其平行的無限長建構直線。按右鍵可選擇其他參考。", + "help.sketch.construction_line.perpendicular": "選取一條草圖直線,再放置與其垂直的無限長建構直線。按右鍵可選擇其他參考。", + "help.sketch.construction_line.tangent_at": "在圓或圓弧上的某一點,或在開放雲形線或圓錐曲線的端點建立切線。請先選取曲線,再選取其切點。", + "help.sketch.construction_line.tangent_external": "選取圓或圓弧,再選取外部點與一個切線解。預覽只會顯示位於所繪圓弧上的切點。", + "help.sketch.construction_line.tangent_two": "選取兩個圓或圓弧,再於預覽中選擇一條公切線。切點必須位於所繪曲線上。", + "help.sketch.construction_line.vertical": "建立通過某一點的鉛直無限長建構直線。其鉛直拘束會在後續編輯中持續維持。", + "help.sketch.equation_curve": "依公式繪製曲線:在指定範圍內使用 x(t) 和 y(t),或 y = f(x)。公式可以使用參數,曲線會隨之更新:可用於正弦波、螺旋線或齒輪齒形的漸開線。", "help.solid.align": "移動所選實體,使其上的點、面或平面與別處的點、面或平面對齊。用於將一個零件貼靠到另一個零件上,而無需測量偏移量。", "help.solid.chamfer": "以指定的距離在所選邊上切出平直的斜面。用於去除銳邊,或使銷釘、螺絲更容易插入。", "help.solid.combine": "結合多個實體、從目標實體中切除,或只保留它們共有的體積,還可以保留工具實體。用於合併零件、用一個形體切割另一個形體,或找出兩個實體重疊的位置。", @@ -1374,6 +1439,8 @@ "help.timeline.compute_all": "從頭重新計算時間軸中的所有特徵。用於重新整理模型,並查看哪些特徵失敗。", "help.timeline.delete": "從時間軸中刪除所選特徵;刪除草圖時,由它建立的特徵也會一併刪除。用於移除不再需要的步驟。", "help.timeline.play": "逐一重播其餘特徵,每次重建後暫停半秒。再次執行此命令或按 Escape 可停止;「移至結尾」會立即跳到結尾。", + "history.sketch_under_constrained": "拘束不足({count} 個自由度)", + "history.sketch_under_constrained_one": "拘束不足({count} 個自由度)", "hole_presets.confirm_delete": "確認刪除", "hole_presets.delete": "刪除", "hole_presets.delete_prompt": "刪除此個人預設?現有的孔不會改變。", @@ -1474,6 +1541,21 @@ "joint.one_component": "這些零件是同一元件的實體:{bodies}。接頭使一個元件相對另一個元件運動,因此每個零件都需要自己的元件。", "joint.rigid_group_one_component": "這 {count} 個實體都位於同一個元件中(或直接位於設計中):請先使用「{command}」。", "joint.rigid_group_select_components": "請選擇兩個或更多元件。", + "joint_move.blocked_hint": "請檢查接頭類型(例如剛性)、固定狀態、接頭限制,以及由參數驅動的值。", + "joint_move.blocked_title": "無法移動此零件", + "joint_move.coarse": "靠近軸心時的旋轉步距(°)", + "joint_move.distance": "與起點的距離", + "joint_move.done": "完成", + "joint_move.fine": "遠離軸心時的旋轉步距(°)", + "joint_move.fixed": "此零件沒有可移動的接頭軸。", + "joint_move.help": "移動 — 拖曳箭頭或圓環。按 Esc 可取消目前的拖曳。", + "joint_move.legacy_hint": "關閉即可使用先前的「移動」面板。", + "joint_move.precision": "拉到虛線輔助線外可進行精細旋轉。", + "joint_move.rotation": "相對起點的旋轉", + "joint_move.select": "請選取一個面或一個組合件零件,然後選擇「移動」。", + "joint_move.settings": "組合件移動", + "joint_move.step": "步距", + "joint_move.use_handles": "組合件「移動」使用接頭運動控點", "keymap.command_id": "命令 ID", "keymap.empty": "沒有符合的命令", "keymap.use_anyway": "仍在此處使用 {key}", @@ -1568,6 +1650,8 @@ "nav.maya.help": "Alt+左鍵拖曳可環轉,Alt+中鍵可平移,Alt+右鍵可縮放;中鍵拖曳同樣可平移", "nav.openscad.help": "在空白處以左鍵拖曳可環繞,Shift+左鍵拖曳可框選,拖曳草圖幾何可編輯;右鍵或中鍵拖曳可平移,Shift+右鍵或 Shift+中鍵拖曳可縮放", "nav.perspective.sketch": "編輯草圖時會使用正交檢視。完成草圖後才能切換為透視。", + "nav.right_drag_orbits.help": "關閉:右鍵拖曳不會移動相機,右鍵只會開啟右鍵選單。選擇 SolidWorks 滑鼠對應會將其關閉。", + "nav.right_drag_orbits.label": "右鍵拖曳環繞", "nav.solidworks.help": "中鍵拖曳可環繞,Ctrl+中鍵可平移,Shift+中鍵可縮放,右鍵拖曳可環繞", "nav.tinkercad.help": "右鍵拖曳可環繞,中鍵或 Shift+右鍵拖曳可平移", "nav.touchpad.help": "按住 Shift 並移動指標可平移;按住 Alt/Option 並移動可旋轉。無需按滑鼠按鈕。垂直捲動可縮放。", @@ -1589,6 +1673,9 @@ "navbar.custom.customise": "自訂導覽列…", "navbar.custom.design": "設計", "navbar.custom.duplicate": "此項目已在導覽列上。", + "navbar.custom.edge": "邊緣", + "navbar.custom.edge_bottom": "底部", + "navbar.custom.edge_top": "頂部", "navbar.custom.fade": "淡化", "navbar.custom.full": "請先移除一個項目,才能新增其他項目。", "navbar.custom.hidden_design": "在設計中隱藏", @@ -1695,6 +1782,7 @@ "parts.dimension.thickness": "厚度", "parts.dimension.width": "寬度", "parts.family.bearing": "深溝滾珠軸承(外形)", + "parts.family.button_head_cap_screw": "內六角半圓頭螺絲", "parts.family.dowel_pin": "圓柱銷", "parts.family.hex_bolt": "六角頭螺栓", "parts.family.hex_nut": "六角螺帽", @@ -1741,6 +1829,8 @@ "prefs.anti_aliasing_note": "平滑鋸齒狀邊緣。", "prefs.applies_on_restart": "重新啟動後生效", "prefs.assistant": "助理", + "prefs.auto_operation": "自動選擇操作", + "prefs.auto_operation_note": "特徵視窗會依新材料接觸到的內容,自動選擇「接合」、「新建實體」或「剪下」,直到您自行選擇為止。關閉時:維持開啟時的操作。", "prefs.axes": "軸", "prefs.axis": "軸", "prefs.bodies_folders": "實體資料夾", @@ -1751,6 +1841,11 @@ "prefs.bottom_centre": "下方置中", "prefs.bottom_right": "右下方", "prefs.browser": "瀏覽器", + "prefs.browser_eye_left": "眼睛圖示置於摺疊箭頭旁", + "prefs.browser_eye_left_note": "瀏覽器各列會照舊把顯示/隱藏的眼睛圖示放在摺疊箭頭旁,而不是放在各列右端的一欄中。", + "prefs.browser_reveal_on_hover": "游標移上時顯示", + "prefs.browser_show_note": "「顯示」會讓瀏覽器保持在畫面上。「游標移上時顯示」會將它隱藏,直到指標移到它的位置或其左緣的細長標籤上;當指標位於其中或從它開啟的選單仍開著時,它會保持開啟。", + "prefs.browser_shown": "顯示", "prefs.built_in": "內建", "prefs.button_left": "左鍵拖曳", "prefs.button_middle": "中鍵拖曳", @@ -1798,6 +1893,8 @@ "prefs.graphics_card_restart": "重新啟動 SolidCraft 後生效。WGPU_ADAPTER_NAME 會覆蓋此選擇。", "prefs.graphics_note": "在速度與平滑度之間取捨。每個選項都會在範例上顯示其效果。", "prefs.graphics_presets_note": "預設集會一併設定下列選項。", + "prefs.hide_constraint_glyphs_zoomed_out": "縮小檢視時隱藏拘束符號", + "prefs.hide_constraint_glyphs_zoomed_out_note": "草圖縮小到拘束符號會使畫面雜亂的程度時,拘束符號會從檢視中消失。關閉時:無論縮放多少,它們都維持固定的螢幕大小。", "prefs.history": "歷程記錄", "prefs.history_origin_bodies": "在頂部顯示原點與實體", "prefs.history_origin_bodies_note": "左側或瀏覽器下方的歷程記錄,會以作用中元件的「實體」資料夾和「原點」開頭,其顯示切換(眼睛圖示)和選單與瀏覽器中相同。底部的歷程記錄維持不變。", @@ -1821,6 +1918,8 @@ "prefs.layout_note": "精簡模式會將面板停靠在視窗邊緣。瀏覽器半透明僅適用於浮動模式。助理的位置請在「助理」頁面中變更。", "prefs.left": "左側", "prefs.left_click": "左鍵點擊", + "prefs.line_drag_draws_one": "按下並拖曳繪製一條直線", + "prefs.line_drag_draws_one_note": "在「直線」工具中,按下、拖曳再放開,會從按下處到放開處繪製一條直線,並在該處結束。按一下仍會繪製連續線段。關閉:拖曳會在放開處放置一個點,與按一下相同。", "prefs.linked_designs": "連結設計", "prefs.live_input": "即時輸入", "prefs.live_preview": "即時預覽", @@ -1834,6 +1933,9 @@ "prefs.max_file_size_step": "STEP 和匯入檔案", "prefs.max_file_size_web": "網頁版可開啟最大 512 MB 的 STEP 和匯入檔案,以及最大 256 MB 的設計:瀏覽器分頁無法容納更大的檔案。桌面版可提高這些上限。", "prefs.megabytes": "MB", + "prefs.menu_grid": "大圖示", + "prefs.menu_list": "清單", + "prefs.menus": "選單", "prefs.minutes": "分鐘", "prefs.minutes_suffix": " min", "prefs.mouse": "滑鼠與觸控板", @@ -1853,14 +1955,40 @@ "prefs.origin_plane_names": "原點平面名稱", "prefs.origin_plane_names_note": "顯示於瀏覽器、對話方塊和清單中的名稱。{top} 為 XY,{front} 為 XZ,{right} 為 YZ,Z 軸朝上。命令、指令碼和已儲存的設計仍使用 XY、XZ 和 YZ。", "prefs.overall_speed": "整體速度", + "prefs.persistent_ribbon_pressed": "醒目顯示作用中的功能區命令", + "prefs.persistent_ribbon_pressed_help": "在功能區命令的效果開啟期間醒目顯示這些命令。關閉可維持先前的中性外觀與命令說明。", + "prefs.perspective_fov": "視角", "prefs.placement": "放置", "prefs.prefer_free_sketch_points": "點重疊時優先選取可移動的草圖點", "prefs.preview_choices": "所選設定的預覽", + "prefs.print_face_highlight": "列印底面醒目提示", + "prefs.print_face_highlight_tip": "在檢視中為實體的列印底面著色及描邊的強度。0 % 不顯示醒目提示。", + "prefs.profile_active": "作用中的設定檔", + "prefs.profile_applied": "已套用設定檔。", + "prefs.profile_changed": "儲存設定檔後設定已有變更。按「儲存」可將變更保存到設定檔中。", + "prefs.profile_deleted": "已刪除設定檔。", + "prefs.profile_export": "匯出…", + "prefs.profile_exported": "已匯出設定檔。", + "prefs.profile_imported": "已匯入設定檔。在「作用中的設定檔」中選擇它即可切換。", + "prefs.profile_name_hint": "設定檔名稱,例如:筆電", + "prefs.profile_rename": "重新命名", + "prefs.profile_renamed": "已重新命名設定檔。", + "prefs.profile_save": "儲存", + "prefs.profile_saved": "目前的設定已儲存至設定檔。", + "prefs.profile_skipped": "已套用設定檔;{n} 項設定保留目前的值(請參閱記錄檔)。", + "prefs.profiles": "設定檔", + "prefs.profiles_note": "將快捷鍵、工具列、導覽、環形選單與介面設定以一個名稱集中保存,並可一次切換。", + "prefs.project_options_dialog": "投影選項對話方塊", + "prefs.project_options_dialog_note": "開啟:投影前先選擇「幾何」或「實體」以及「投影連結」。關閉:沿用先前直接點選的工作流程。", "prefs.quality_default": "預設", "prefs.quality_fastest": "最快", "prefs.quality_slowest": "最慢", "prefs.quality_smoother": "更平滑", + "prefs.render_previous_geometry_budget": "使用先前的彩現預覽幾何預算", + "prefs.render_previous_geometry_budget_help": "還原先前固定的幾何上限(64 位元桌面版為 512 MiB,其他平台為 128 MiB),而非依裝置決定的預算。", "prefs.reports_saved": "已儲存 {n} 份", + "prefs.ribbon_reveal": "在功能區中顯示搜尋結果", + "prefs.ribbon_reveal_note": "在「搜尋命令」中將指標停留在結果上,會顯示該命令在功能區中的位置:其索引標籤、按鈕,或面板下拉選單中的列。從搜尋啟動的命令會在該處閃爍,而從下拉選單執行中的命令會標示其所在面板。", "prefs.right_click": "右鍵點擊", "prefs.saving": "自動儲存與復原", "prefs.scale_note": "也可在視窗中使用 Ctrl + / Ctrl −。100% 時按鈕為 28 px。", @@ -1869,21 +1997,34 @@ "prefs.search_empty": "沒有符合的設定", "prefs.select": "選擇", "prefs.select_one_item": "選擇一個項目", + "prefs.select_other_hidden": "以「選擇其他」選取隱藏的幾何", + "prefs.select_other_hidden_help": "開啟:納入指標附近隱藏的實體、草圖與被遮住的邊。關閉:沿用原本指標下可見幾何的清單。", "prefs.selecting": "選擇", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift 僅新增;Ctrl / Cmd 切換選擇", "prefs.shift_add_note": "開啟:Shift 新增,Ctrl / Cmd 切換選擇。關閉(預設):兩者皆切換選擇。", "prefs.shortcuts": "快捷鍵", + "prefs.single_surface_analysis": "一次只使用一種曲面分析", + "prefs.single_surface_analysis_help": "關閉時,曲面著色與「連續性」可同時開啟。開啟後只保留最後啟動或變更的分析。", + "prefs.sketch_align_points": "繪製時與其他草圖點對齊", + "prefs.sketch_align_points_note": "繪製時,點可同時與一個草圖點水平對齊、並與另一個草圖點垂直對齊,並顯示點線輔助線,按一下會保留兩個對齊。關閉:只與前一個點對齊,一次一個軸向。", "prefs.sketch_conflict_colour": "衝突幾何以衝突顏色顯示", "prefs.sketch_conflict_colour_note": "草圖的拘束發生衝突時,失敗拘束所涉及的曲線與點也會以衝突顏色繪製,而不只是其符號或尺寸。關閉時:維持原本的顏色。", + "prefs.sketch_construction_lines": "延伸建構直線工具", "prefs.sketch_exit_view": "草圖結束時", "prefs.sketch_exit_view.keep": "保持目前檢視", "prefs.sketch_exit_view.previous": "返回先前的檢視", "prefs.sketch_exit_view.unless_rotated": "未旋轉時返回", "prefs.sketch_exit_view_note": "「完成草圖」、取消或連按兩次 Esc 後檢視的去向。", + "prefs.sketch_open_look_at": "開啟草圖時轉向正對草圖", + "prefs.sketch_open_look_at_note": "關閉:建立或編輯草圖時維持目前的檢視。需要時可用「注視」正對草圖。", + "prefs.sketch_recent_snap": "自動鎖點至最近使用的圓心與中點", + "prefs.sketch_recent_snap_note": "繪製時,游標會鎖點至最近使用的兩個圓心或直線邊中點的輔助線交會處。關閉時:這些輔助線僅供顯示,游標不會移動。", "prefs.sketch_selection": "草圖與選取", "prefs.sketch_selection_heading": "草圖與選取", "prefs.sketch_selection_note": "草圖如何貼齊,以及點擊如何選取。", + "prefs.sketch_state_icons": "草圖圖示以其狀態的顏色顯示", + "prefs.sketch_state_icons_note": "在歷程記錄與瀏覽器中,草圖圖示會採用其狀態的顏色(拘束不足、完全拘束或衝突),其工具提示也會註明該狀態。關閉時:所有草圖使用相同圖示。", "prefs.sketch_tab_always": "保持顯示「草圖」索引標籤", "prefs.sketch_tab_always_note": "沒有開啟草圖時,「草圖」索引標籤仍保留在功能區中,位於「模型」之後。使用其繪圖工具時,會在所選的平面或面上、或在你接著選取的平面上開始草圖,並直接啟用該工具。", "prefs.sketch_tracking_guides": "圓心與中點對齊輔助線", @@ -1893,6 +2034,11 @@ "prefs.source": "來源", "prefs.speed": "速度", "prefs.stand_up_note": "以 Z 軸向上開啟 STEP 或 IGES 檔案後,詢問是否繞 X 軸旋轉 90°。", + "prefs.stand_up_unknown": "來自未知程式的檔案", + "prefs.stand_up_unknown.ask": "在對話方塊中詢問", + "prefs.stand_up_unknown.note": "顯示提示", + "prefs.stand_up_unknown.off": "不提供", + "prefs.stand_up_unknown_note": "當檔案標頭沒有指出任何已知以 Y 軸向上儲存的程式時,要如何提出詢問。「顯示提示」只會在檔案看起來平躺時顯示;「在對話方塊中詢問」則和以往一樣,對每一個這類檔案都會詢問。", "prefs.status_bar": "狀態列", "prefs.status_bar_note": "視窗底部的細長列會顯示正在編輯的內容、以顏色表示的開啟中草圖狀態,以及設計的單位;按一下單位即可變更。草圖狀態就不會再顯示在檢視的角落。", "prefs.status_done": "完成", @@ -1918,7 +2064,14 @@ "prefs.toast_place": "通知位置", "prefs.toggle_item": "加入此項目,或再次移除", "prefs.toolbar": "工具列", + "prefs.toolbar_tabs": "工具列索引標籤", "prefs.top": "頂部", + "prefs.trackpad_zoom_modifier": "按住以下按鍵時以雙指捲動縮放", + "prefs.trackpad_zoom_modifier.alt": "⌥ / Alt", + "prefs.trackpad_zoom_modifier.cmd": "⌘", + "prefs.trackpad_zoom_modifier.ctrl": "Ctrl", + "prefs.trackpad_zoom_modifier.help": "只按住此按鍵並垂直捲動,即可在指標處縮放。「關閉」則保留現有的捲動行為。", + "prefs.trackpad_zoom_modifier.off": "關閉", "prefs.translucent": "半透明", "prefs.under_browser": "瀏覽器下方", "prefs.under_cursor": "游標下方", @@ -1928,13 +2081,24 @@ "prefs.view_confirm_corner": "在檢視角落顯示「確定」與「取消」", "prefs.view_confirm_corner_note": "開啟對話框或草圖時,檢視右上角的勾號與叉號,作用等同於對話框的「確定」與「取消」,或完成與取消草圖。檢視導覽器會下移到它們下方。", "prefs.viewport": "視埠", + "prefs.viewport_analysis_status": "顯示視埠分析狀態", + "prefs.viewport_analysis_status_help": "在視埠上方顯示作用中的分析,可從各項目開啟其圖例,並以 Esc 關閉最新的分析。關閉可還原角落圖例與先前的 Esc 行為。", + "prefs.viewport_background": "背景", + "prefs.viewport_background_colour": "顏色", + "prefs.viewport_background_theme": "主題漸層", "prefs.wheel": "滾輪", "prefs.wheel_trackpad": "滾輪與觸控板", "prefs.workspace": "工作區", + "prefs.zoom_stop_at_model": "縮放在模型處停止", + "prefs.zoom_stop_at_model_tip": "3D 滑鼠與裝置應用程式會朝檢視中央的表面縮放;拖曳縮放會固定在開始拖曳的點。關閉:縮放可穿過模型。滾輪與雙指捏合另由「縮放到游標」控制", "prefs.zoom_toward_cursor": "縮放,朝向游標", "properties.title": "屬性:{name}", "recovery.days_ago": "{count} 天前", "recovery.detail": "{file} · {features} 個特徵 · 自動儲存於 {when}", + "recovery.discard_all": "全部捨棄", + "recovery.discard_all_detail": "此操作無法復原。", + "recovery.discard_all_title": "捨棄 {count} 個自動儲存檔?", + "recovery.discard_all_title_one": "捨棄 1 個自動儲存檔?", "recovery.hours_ago": "{count} 小時前", "recovery.just_now": "剛剛", "recovery.min_ago": "{count} 分鐘前", @@ -2019,6 +2183,7 @@ "render.appearance_editor.weight": "權重", "render.appearance_physics_separate": "視覺外觀不會指定密度、熱學資料或其他工程屬性。", "render.appearances": "外觀", + "render.assign_help": "按一下以套用至選取項目;拖曳可預覽並放下,或在未選取任何項目時按一下以塗抹。", "render.availability_native": "原生組合;套用前請先檢閱", "render.availability_projection": "支援的投影;請參閱範圍與省略項目", "render.availability_reference": "參考資料;不會自動指派", @@ -2057,6 +2222,7 @@ "render.bloom_threshold": "線性高光臨界值", "render.browse_capabilities": "檢查完整功能目錄", "render.browse_environments": "瀏覽原創 HDR 環境", + "render.browse_looks": "瀏覽…", "render.browse_native_scenes": "瀏覽原生 Studio", "render.browse_physical": "檢查物理參考資料", "render.browse_quality": "檢查品質規格", @@ -2080,6 +2246,82 @@ "render.check_opaque": "透明度:僅支援不透明表面;支援背景 Alpha。", "render.check_sorted_alpha": "透明度:排序 Alpha 近似。", "render.check_transmission": "透明度:物理透射。", + "render.children.absolute_floor": "絕對下限", + "render.children.adaptive": "自適應", + "render.children.add_look_reason": "新增風格以編輯其啟用狀態與強度。", + "render.children.all_shots": "所有鏡頭", + "render.children.bloom": "泛光", + "render.children.check": "檢查", + "render.children.check_now": "立即檢查", + "render.children.chromatic": "色差", + "render.children.compact": "精簡", + "render.children.contrast": "對比", + "render.children.copy_image": "複製", + "render.children.copy_job": "複製工作 JSON", + "render.children.copy_request": "複製請求 JSON", + "render.children.coverage_error": "覆蓋率誤差", + "render.children.cpu": "CPU 光線追蹤", + "render.children.denoise": "降低完成影像的雜訊", + "render.children.depth": "路徑深度", + "render.children.distortion": "畸變", + "render.children.edge_samples": "邊緣取樣數", + "render.children.effects_banner": "這些效果需要 GPU 路徑追蹤。開啟此處絕不會變更方法。", + "render.children.enable_adaptive": "啟用獨立先導取樣以編輯自適應限制。", + "render.children.enable_bloom": "啟用泛光以編輯其強度、臨界值與半徑。", + "render.children.finishing": "後製", + "render.children.flare": "眩光", + "render.children.fresnel": "菲涅耳分層", + "render.children.ghosts": "鬼影環", + "render.children.glass": "玻璃傳輸", + "render.children.go_to": "前往{editor}", + "render.children.grain": "顆粒", + "render.children.halo": "光暈", + "render.children.hdr": "HDR", + "render.children.keep_master": "保留原始線性母檔", + "render.children.last_result": "上次:{number} · {width} × {height} · {phase}", + "render.children.lens_effects": "鏡頭效果", + "render.children.look_summary": "{tone} · {count} 個風格", + "render.children.looks": "創意風格", + "render.children.memory": "記憶體", + "render.children.method": "方法", + "render.children.minimum": "最小值", + "render.children.needs_cpu": "需要 CPU 光線追蹤。開啟此處絕不會變更方法。", + "render.children.nested": "經認證的巢狀介電質", + "render.children.not_checked": "未檢查", + "render.children.path": "路徑追蹤", + "render.children.pilots": "先導取樣數", + "render.children.preview": "預覽資料", + "render.children.progressive_reason": "選擇「漸進式」以編輯其取樣數與種子。", + "render.children.radius": "半徑", + "render.children.reflect_depth": "反射深度", + "render.children.reflect_samples": "反射取樣數", + "render.children.regrade": "重新調色", + "render.children.relative_error": "相對誤差", + "render.children.render": "彩現", + "render.children.render_shot": "彩現鏡頭", + "render.children.renderer": "彩現器", + "render.children.renderers": "各彩現器", + "render.children.request": "此請求", + "render.children.reset_finishing": "重設後製", + "render.children.review_quality": "檢閱品質規格…", + "render.children.rgb_gain": "RGB 增益", + "render.children.samples": "取樣數", + "render.children.sampling": "取樣", + "render.children.saturation": "飽和度", + "render.children.scene_summary": "{bodies} 個實體 · {triangles} 個三角形", + "render.children.seed": "種子", + "render.children.sharpening": "銳利化", + "render.children.single_image": "單張影像", + "render.children.soft_focus": "柔焦", + "render.children.streak": "光條", + "render.children.strength": "強度", + "render.children.studio": "Studio", + "render.children.threshold": "臨界值", + "render.children.tone": "色調", + "render.children.under_hood": "技術細節", + "render.children.vignette": "暈影", + "render.children.workstation": "工作站", + "render.choose_look": "請先在「外觀」中選擇風格", "render.chromatic_offset": "邊緣色差偏移", "render.clear_stage": "移除已儲存的佈景", "render.copy_request": "複製 JSON", @@ -2331,8 +2573,33 @@ "render.flare_strength": "亮部眩光強度", "render.flare_threshold": "場景線性眩光閾值", "render.fold_browser": "摺疊瀏覽器", + "render.frame_aspect": "長寬比", + "render.frame_centre_mark": "中心標記", + "render.frame_copy_limit": "沒有可用的複製鏡頭名稱", + "render.frame_copy_name": "{name} 副本 {number}", + "render.frame_custom": "自訂", + "render.frame_distance_unit": " × 大小", + "render.frame_duplicate": "複製", + "render.frame_exposure": "曝光", + "render.frame_fov": "視角", + "render.frame_guides": "輔助線", + "render.frame_invalid_aspect": "無效的長寬比預設", + "render.frame_lens": "視角 {fov}° · ≈ {mm} mm", + "render.frame_name": "名稱", + "render.frame_output_size": "輸出大小", + "render.frame_photo": "3:2 · 相片", + "render.frame_portrait": "4:5 · 人像", "render.frame_presets": "畫面預設", "render.frame_presets_pending": "將隨「鏡頭」編輯器推出", + "render.frame_projection": "投影", + "render.frame_render_shot": "彩現此鏡頭", + "render.frame_rule_thirds": "三分法", + "render.frame_safe_area": "安全區域", + "render.frame_shot_title": "鏡頭 · {name}", + "render.frame_square": "1:1 · 正方形", + "render.frame_tall": "9:16 · 直式", + "render.frame_thirds": "三分線", + "render.frame_wide": "16:9 · 寬螢幕", "render.gpu_depth": "最大路徑深度", "render.gpu_options_help": "取樣數、深度、種子和曝光會隨此設定一併儲存。僅儲存目前啟用的影像方法。", "render.gpu_path_failed": "GPU 路徑追蹤器無法使用", @@ -2360,6 +2627,7 @@ "render.heading_results": "結果", "render.heading_shots": "鏡頭", "render.hide_shots_label": "隱藏鏡頭列", + "render.hover_look": "{object} · {look} · 按一下以選取", "render.image_advanced": "進階 GPU 控制項", "render.image_cancelled": "彩現已取消", "render.image_cancelling": "正在取消", @@ -2506,17 +2774,95 @@ "render.library_uniform_ack": "使用均勻近似,不含空間或進階效果", "render.library_usable": "可執行的投影", "render.library_users": "套用對象", + "render.light_aim": "對準", "render.light_anchor_pending": "尚無法變更燈光座標系。", + "render.light_azimuth": "方位角", + "render.light_backend_limit": "請選擇 CPU 光線追蹤或明確的 GPU 攝影棚以編輯燈光。舊版 GPU 攝影棚燈光仍為唯讀。", + "render.light_behaviour": "行為", + "render.light_default_fill": "補光", + "render.light_default_key": "主光", + "render.light_default_number": "燈光 {number}", + "render.light_default_rim": "輪廓光", + "render.light_delete": "刪除", + "render.light_distance": "距離", + "render.light_duplicate": "複製", + "render.light_elevation": "仰角", + "render.light_gpu_punctual": "點光源與聚光燈可搭配 CPU 光線追蹤使用。", + "render.light_hard_shadow": "此燈光為硬陰影;無法使用發光體柔和度。", + "render.light_intensity": "強度", + "render.light_missing": "此燈光已不存在。請在瀏覽器中選取燈光。", + "render.light_place_highlight": "依高光放置", + "render.light_place_pending": "依高光放置需要在視埠中選取燈光,目前尚不支援。", + "render.light_position": "位置", + "render.light_preview_help": "即時檢視不含陰影;面光源使用中心取樣近似。彩現時使用完整的燈光模型。", + "render.light_product_centre": "產品中心", + "render.light_size": "大小", + "render.light_softness": "柔和度", + "render.light_temperature": "色溫", + "render.light_temperature_help": "近似黑體色調;以線性 RGB 儲存。", + "render.light_type": "類型", + "render.light_type_area": "面光源", + "render.light_type_point": "點光源", + "render.light_type_spot": "聚光燈", + "render.light_type_sun": "太陽", + "render.light_view_only": "獨奏與靜音只影響此檢視及其彩現;絕不會儲存在設計中。", + "render.light_world_origin": "世界原點", + "render.lighting_aim_point": "對準點", + "render.lighting_apply": "套用預設", + "render.lighting_area_samples": "面光源取樣數", + "render.lighting_budget": "預算", + "render.lighting_budget_help": "數量為唯讀。新增、複製或刪除燈光即可變更;上限由方法決定。", + "render.lighting_colour": "顏色", + "render.lighting_consent": "以此預設取代方向光", + "render.lighting_custom": "自訂燈光", + "render.lighting_custom_help": "選擇「自訂」會將預設轉換為可編輯的燈光,每盞燈光在樹狀結構中各佔一列。", + "render.lighting_direction": "方向 X Y Z", + "render.lighting_direction_help": "或輸入朝向光源的方向(X Y Z)。", + "render.lighting_distance_unit": " × 大小", + "render.lighting_edge_u": "U 邊向量", + "render.lighting_edge_v": "V 邊向量", + "render.lighting_falloff": "衰減", + "render.lighting_falloff_help": "強度以參考距離處的值表示。", + "render.lighting_height": "高度 V", + "render.lighting_minimum": "最小距離", + "render.lighting_mute": "靜音此燈光", + "render.lighting_point": "點", + "render.lighting_preset": "攝影棚預設", + "render.lighting_preset_cpu": "方向光攝影棚預設需要 CPU 光線追蹤。GPU 照明使用明確的太陽光與面光源。", + "render.lighting_preset_row": "預設", + "render.lighting_radiance": "輻射亮度", + "render.lighting_reference": "參考距離", + "render.lighting_review_help": "取代方向光清單。保留點光源與面光源、環境、陰影、相機及所有其他鏡頭設定。", + "render.lighting_review_title": "檢閱照明預設", + "render.lighting_samples_cpu": "面光源取樣數適用於 CPU 光線追蹤。", + "render.lighting_samples_need_area": "新增 CPU 面光源以設定面光源取樣數。", + "render.lighting_samples_reason": "面光源取樣數必須為 1、4、16 或 64。", + "render.lighting_shadow_cpu": "陰影開關需要 CPU 光線追蹤。GPU 路徑追蹤一律會投射陰影。", + "render.lighting_shadows": "投射陰影", + "render.lighting_shadows_section": "陰影與取樣", + "render.lighting_shape": "形狀", + "render.lighting_solo": "獨奏此燈光", + "render.lighting_source": "來源", + "render.lighting_source_section": "光源", + "render.lighting_spot_preserved": "此點光源會保留已儲存的聚光錐角,直到變更其類型為止。", + "render.lighting_sun_distance": "太陽方向與距離和對準點無關。", + "render.lighting_unsupported": "不支援", + "render.lighting_used": "{used} / {limit}", + "render.lighting_width": "寬度 U", + "render.lighting_world_position": "世界 X Y Z", "render.lights_fixed_to": "燈光固定於", "render.linear_help": "在降噪、曝光、色調對應、濾鏡與背景之前,保留 32 位元浮點 RGBA 母檔。線性 sRGB/D65;覆蓋率預乘。額外記憶體須明確核可。無壓縮,也無額外通道。", "render.linear_retain": "保留原始線性 EXR", "render.linear_unavailable": "所選的已完成畫面沒有保留 EXR。請在彩現前啟用保留;較舊的 PNG 無法還原原始輻射亮度。", "render.look_add": "新增色彩風格", + "render.look_applied": "已套用 {name}", "render.look_bleach": "仿留銀沖印風格", "render.look_cool": "冷色", "render.look_cyanotype": "仿藍曬風格", "render.look_faded": "褪色相片", + "render.look_material": "材質", "render.look_mono": "單色", + "render.look_pasted": "已貼上風格", "render.look_sepia": "深褐色", "render.look_strength": "風格強度", "render.look_teal_orange": "青綠 / 橘橙", @@ -2592,9 +2938,11 @@ "render.output_advanced": "進階", "render.output_backend_ready": "就緒", "render.output_check": "檢查", + "render.output_check_failed": "檢查 · 錯誤", "render.output_check_help": "執行「檢查」可在不開始彩現的情況下檢閱此請求。", "render.output_check_summary": "檢查 · 備註:{count}", "render.output_copy": "複製", + "render.output_copy_report": "複製報告", "render.output_destination": "目的地", "render.output_destination_help": "匯出完成的影像時,請選擇目的檔案。", "render.output_dimensions": "大小", @@ -2604,6 +2952,7 @@ "render.output_fit": "適合", "render.output_format": "格式", "render.output_format_status": "PNG 是確切的已完成顯示影像。選用的 GPU EXR 會在顯示濾鏡之前,保留原始場景線性 FLOAT32 輻射亮度。匯出絕不會以預覽快取或其他工作取代。", + "render.output_grading": "調色", "render.output_image": "影像", "render.output_largest": "最大 {width} × {height}", "render.output_lock_ratio": "鎖定長寬比", @@ -2613,6 +2962,8 @@ "render.output_quality_waiting": "等待品質約定", "render.output_ratio_refused": "連動的尺寸超出此方法的大小上限。先前的尺寸會保留;請解除鎖定比例,或選擇較小的值。", "render.output_ready": "可開始彩現", + "render.output_run_check": "執行檢查", + "render.output_size_limit": "{method}:每邊最多 {dimension} px,總計 {pixels} 像素。可容納的最大尺寸:{width} × {height}。", "render.output_size_ok": "大小正常 · {width} × {height} px", "render.output_swap_orientation": "交換寬度與高度", "render.output_tools_inspector": "請在「輸出」檢查器中編輯尺寸與格式。", @@ -2629,6 +2980,7 @@ "render.page_output": "輸出", "render.page_scene": "場景", "render.paint_label": "塗料", + "render.painting_with": "正在以 {name} 塗抹", "render.passive_stage": "地面與被動佈景", "render.passive_stage_help": "有限的被動面板僅屬於彩現影像,不會新增 CAD 實體或質量。放置位置在套用後即固定;與產品邊界相交時會被拒絕。CPU 不提供漫射反彈照明。", "render.perspective": "透視相機", @@ -2650,6 +3002,7 @@ "render.pick_help": "選擇實體、面或元件。點擊空白處可清除;按住 Shift 或 Ctrl 可加選或切換。醒目提示僅會顯示於即時產品檢視中。", "render.pick_label": "選取", "render.pick_up_label": "拾取", + "render.pick_up_prompt": "按一下物件以吸取其風格", "render.pick_waiting": "尚未支援產品選取。", "render.post_contrast": "對比", "render.post_gain_b": "藍色增益", @@ -2689,13 +3042,17 @@ "render.radial_distortion": "徑向畸變", "render.radial_fit": "適合產品檢視", "render.radial_save": "儲存鏡頭", + "render.recent_looks": "最近使用的風格", "render.regrade_action": "重新調色原始母檔(不降噪)", "render.regrade_help": "以這些呈現控制項,從所選的已完成原始母檔建立獨立的 PNG。不會追蹤光線、變更已擷取的尺寸/相機/光源,也不會覆寫 EXR。不會重新套用降噪:原始母檔不保留其引導資料。衍生檔仍可透過相同的工作控制項匯出。", "render.regrade_label": "重新調色", "render.reload_settings": "重新載入已儲存設定", + "render.remove_override": "移除覆寫", + "render.replace_all": "全部取代", "render.replace_ground": "確認取代現有佈景", "render.replace_label": "取代", "render.replace_saved_surface": "取代此目標現有的已儲存繫結", + "render.replace_with": "取代為", "render.restore_camera": "還原已儲存相機", "render.result_actual": "100%", "render.result_actual_help": "每個顯示像素對應一個影像像素。", @@ -2747,14 +3104,28 @@ "render.saved_surface_remove": "移除已儲存的繫結", "render.saved_surfaces": "已儲存的表面繫結", "render.saved_surfaces_help": "固定的內建貼圖以局部毫米為單位,跟隨所選的實例。目標被取代或遺失時會視為錯誤,絕不會自動重新指派。", + "render.scene_all_shots": "所有鏡頭({count})", "render.scene_apply": "套用已檢閱的攝影棚", + "render.scene_batch_reason": "「所有鏡頭」需要已儲存鏡頭的彩現佇列,目前尚無法使用。", + "render.scene_check_now": "立即檢查", + "render.scene_check_passed": "檢查通過 · {notes} 則備註", + "render.scene_check_stale": "檢查已過期 · 請重新檢查", + "render.scene_check_unchecked": "尚未檢查 · 0 則備註", "render.scene_consent": "確認取代燈光、環境、曝光與佈景", + "render.scene_light_summary": "{environment} · {lights} 盞燈光", + "render.scene_look_summary": "{looks} 個風格 · {overrides} 個覆寫", + "render.scene_open_check": "開啟檢查", "render.scene_preserved_fields": "相機、取樣、尺寸、光學繫結、CAD 幾何與工程材質皆會保留。", "render.scene_preserves_design": "為可見的主體組合燈光與被動佈景。幾何、工程材質與相機維持獨立。", + "render.scene_ready": "可開始彩現", + "render.scene_reminder": "場景會保留設計。彩現絕不會變更幾何、工程屬性與 CAD 資料。", + "render.scene_reminders": "提醒", + "render.scene_renderer_ready": "{renderer} 已就緒", "render.scene_replaces": "取代內容", "render.scene_review": "解析並檢閱", "render.scene_review_help": "套用前請檢閱完整的原生組合。未繫結的進階 SCENE 規格不會被替代。", "render.scene_scope_details": "組合範圍", + "render.scene_stages": "階段", "render.search_appearances": "搜尋 {count} 個外觀", "render.setup_legacy_environment": "此舊版設定使用傳統的環境旋轉或強度。這些值會保留;儲存完整影像工作前,請明確轉換它們。", "render.setup_name": "設定名稱", @@ -2767,7 +3138,22 @@ "render.shell_tab_camera": "彩現相機", "render.shell_tab_lighting": "照明", "render.shell_tab_scene": "彩現場景", + "render.shot_aperture": "光圈", + "render.shot_distance": "距離", + "render.shot_dof": "景深", "render.shot_duplicate": "複製已儲存的鏡頭", + "render.shot_edit_output": "在「輸出」中編輯", + "render.shot_exposure_reason": "曝光需要 CPU 光線追蹤或 GPU 路徑追蹤。", + "render.shot_exposure_section": "曝光", + "render.shot_focus_distance": "對焦距離", + "render.shot_fov_reason": "視角需要透視投影。", + "render.shot_frame_subject": "取景主體", + "render.shot_invalid_camera": "相機數值必須為有限值且在範圍內。", + "render.shot_lens": "透鏡", + "render.shot_lens_enable_reason": "啟用景深以編輯光圈與對焦距離。", + "render.shot_lens_gpu_reason": "景深需要 GPU 路徑追蹤。", + "render.shot_lens_perspective_reason": "景深需要透視投影。", + "render.shot_look_selection": "注視所選項目", "render.shot_manager": "已儲存的鏡頭", "render.shot_new_name": "新鏡頭名稱", "render.shot_remove": "移除已儲存的鏡頭", @@ -2775,6 +3161,9 @@ "render.shot_remove_title": "移除已儲存的鏡頭?", "render.shot_rename": "重新命名已儲存的鏡頭", "render.shot_saved_only": "請先儲存目前的草稿。複製與重新命名會保留確切的已儲存選項與資源參照。", + "render.shot_selection_reason": "請選取要對準的實體或面。", + "render.shot_size": "大小", + "render.shot_target": "目標", "render.shots_camera_changed": "儲存此鏡頭後相機已變更", "render.shots_name_help": "請為目前的鏡頭輸入唯一的名稱。", "render.shots_no_result": "尚無彩現", @@ -2997,6 +3386,13 @@ "render.wait_pick_up": "尚未支援:產品拾取與外觀吸取", "render.wait_replace": "尚未支援:取代外觀的所有使用處", "render.wait_solo": "尚未支援:燈光可見性與獨奏控制項", + "render.wear_applies_to": "套用於", + "render.wear_layer_count": "{count} 層", + "render.wear_layer_one": "{count} 層", + "render.wear_layers": "層", + "render.wear_no_layers": "沒有層", + "render.wear_selected_layer": "所選層", + "render.wear_title": "磨損", "sample.bracket.about": "以 2 mm 規則建立的基礎凸緣、邊線凸緣和摺邊", "sample.bracket.name": "鈑金支架", "sample.enclosure.about": "薄殼盒體,含唇緣、螺絲凸座、墊塊和卡扣", @@ -3030,6 +3426,7 @@ "select an axis": "請選取一個軸", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "請先選取軸、直邊或圓邊,或圓柱面、圓錐面或圓環面", "select bodies first": "請先選取實體", + "select components first": "請先選取元件", "select corner points, or the two lines of one corner": "請選取角點,或同一個角的兩條直線", "select one corner point, or the two lines of one corner": "請選取一個角點,或同一個角的兩條直線", "select sheet edges first": "請先選取鈑金邊", @@ -3048,6 +3445,7 @@ "settings.double_click_components": "按兩下元件以啟用它,或就地編輯連結的元件", "settings.frame_rate_details_note": "加入最近各影格耗時的圖表(疊加繪製的三角形數量)、影格耗時直方圖,以及 GPU 繪製的內容。點擊檢視中的影格率即可顯示或隱藏。", "settings.frame_rate_note": "在檢視右下角顯示每秒影格數。", + "settings.insert_lists_open_designs": "「插入設計」會優先列出在其他分頁中開啟的設計", "settings.measure_axis_distances": "顯示 X/Y/Z 距離", "settings.measure_axis_distances_help": "開啟測量時,在總距離之外顯示沿世界座標軸方向的分量尺寸。", "settings.msaa_applies_on_restart": "抗鋸齒將在下次啟動 SolidCraft 時生效", @@ -3072,6 +3470,10 @@ "shortcut.unpin": "取消固定", "sketch is missing": "草圖不存在", "sketch.degrees_of_freedom": "{count} 個自由度", + "sketch.dimension_added_driven": "草圖已決定該尺寸,因此已將其新增為從動(參考)尺寸", + "sketch.dimension_applied": "已套用尺寸", + "sketch.dimension_applied_defined": "已套用尺寸;已定義參數 `{name}`", + "sketch.dimension_applied_renamed": "已套用尺寸;參數已命名為 `{name}`", "sketch.dof_left": "剩餘 {count} 個自由度", "sketch.dof_left_one": "剩餘 {count} 個自由度", "sketch.draw_curves": "繪製為 {linetype}", @@ -3106,6 +3508,8 @@ "sketch_text.use_font_file": "使用字型檔案 {path}", "solid": "實體", "status.editing": "正在編輯 {name}", + "status.navigation": "滑鼠對應:{mapping}", + "status.navigation_tip": "滑鼠移動檢視的方式。按一下可選擇其他對應", "status.under_constrained": "拘束不足({count} 個自由度)", "status.under_constrained_one": "拘束不足({count} 個自由度)", "status.units_tip": "設計的單位。按一下可變更", @@ -3139,16 +3543,20 @@ "timeline.delete_folders_tip": "在歷程記錄中保留其中的特徵與巢狀資料夾", "timeline.delete_folders_with_features_tip": "刪除資料夾及其中的所有特徵與巢狀資料夾", "timeline.delete_many": "刪除所選的 {count} 個特徵", + "timeline.edited_rolled_back": "{name} 已變更,但它位於歷程標記之後,因此不會建構:將標記移到它之後即可看到結果", "timeline.feature_error": "錯誤:{message}", "timeline.feature_hint": "雙擊以編輯 · 拖曳以重新排序 · 按右鍵查看更多", + "timeline.feature_rolled_back": "已回溯:位於歷程標記之後,因此不會建構", "timeline.feature_warning": "警告:{message}", "timeline.group": "新建特徵資料夾", "timeline.move_many": "移動時間軸項目", "timeline.move_to_group": "將特徵移至資料夾", "timeline.rename_group": "重新命名特徵資料夾", "timeline.reorder_many": "特徵重新排序", + "timeline.suppress_features": "抑制特徵", "timeline.suppress_folders_tip": "抑制或取消抑制資料夾及其巢狀資料夾中的所有特徵", "timeline.ungroup": "移除特徵資料夾", + "timeline.unsuppress_features": "取消抑制特徵", "to the face": "到面", "to the next face": "到下一個面", "toolbar.customize_note": "{tab} 索引標籤。勾選命令可將其按鈕新增到工具列。您也可以將面板下拉選單中的某一行拖到工具列,將按鈕拖出工具列以移除,或使用下拉選單行中的固定圖示。", @@ -3220,6 +3628,10 @@ "ui.3d_mouse_offer": "校準 3D 滑鼠可以修正互換或反向的軸,並調整它的速度和手感。", "ui.3d_mouse_offer_never": "不再顯示", "ui.3d_mouse_offer_swap": "切換到 Hidraw(直接驅動)", + "ui.3d_mouse_orbit": "環轉", + "ui.3d_mouse_orbit_classic": "傳統", + "ui.3d_mouse_orbit_tip": "追蹤:3D 滑鼠會繞著開始旋轉時位於檢視中央的模型部位旋轉檢視,並以淡色小球標示。傳統:繞著檢視中心旋轉", + "ui.3d_mouse_orbit_traced": "追蹤", "ui.3d_mouse_reading": "讀值", "ui.3d_mouse_reset_axes": "重設軸指定", "ui.3d_mouse_reset_view": "重設檢視", @@ -3256,6 +3668,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "逆時針扭轉搖桿帽,保持住,然後鬆開。", "ui.3d_mouse_switch_cap": "改以回中型校準", "ui.3d_mouse_switch_trackball": "改以軌跡球校準", + "ui.3d_mouse_use_direct": "使用直接驅動", + "ui.3d_mouse_use_direct_tip": "直接讀取 3D 滑鼠(hidraw),不經過 spacenavd。", "ui.3d_mouse_view_axis": "檢視軸", "ui.3d_mouse_view_off": "關閉", "ui.3d_mouse_view_off_tip": "3D 滑鼠驅動程式的設定檔關閉了此軸,因此它的動作不會移動檢視。在此選擇另一個軸的動作會將兩者互換,該動作將不再移動檢視。", @@ -3562,6 +3976,8 @@ "ui.cancel": "取消", "ui.canvases": "畫布", "ui.center": "中心", + "ui.center_arc_follows_cursor": "中心點圓弧跟隨游標的方向", + "ui.center_arc_follows_cursor_tip": "中心點圓弧會依游標繞其圓心的方向,順時針或逆時針繪製。關閉時:一律從起點起逆時針繪製。", "ui.centerline": "中心線", "ui.chain": "鏈", "ui.chamfer": "倒角", @@ -3570,6 +3986,7 @@ "ui.circle": "圓", "ui.circular_pattern": "環形陣列", "ui.clear": "清除", + "ui.clear_print_face": "清除列印底面", "ui.clear_recent": "清除最近", "ui.clear_the_selection": "清除選擇", "ui.click_selects_whole_bodies": "點擊選擇整個實體", @@ -3604,6 +4021,15 @@ "ui.constant": "恆定", "ui.constraints_conflict": "拘束條件衝突", "ui.construction": "建構", + "ui.construction.angle_hint": "建構直線:按一下一點,再輸入角度或選取方向", + "ui.construction.bisector_hint": "角平分線:選取兩條相交的直線,再選取所需的解", + "ui.construction.common_hint": "公切線:選取兩個圓/圓弧,再選取所需的解", + "ui.construction.contact_hint": "切線:選取圓/圓弧或開放雲形線/圓錐曲線,再選取切點(自由曲線:端點)", + "ui.construction.external_hint": "切線:選取圓或圓弧、一個外部點,再選取所需的解", + "ui.construction.free_hint": "建構直線:按一下兩點;按右鍵可重新開始", + "ui.construction.point_hint": "建構直線:按一下要通過的點", + "ui.construction.reference_hint": "建構直線:選取一條直線,再選取一點;按右鍵可變更參考", + "ui.construction.ribbon_label": "輔助線", "ui.context_menu": "右鍵選單", "ui.continuity_g0": "G0 摺痕", "ui.continuity_g1": "G1 相切", @@ -3695,6 +4121,14 @@ "ui.end_scale": "末端縮放", "ui.entities": "物件", "ui.equal": "相等", + "ui.equation_end": "結束", + "ui.equation_explicit": "顯式", + "ui.equation_form": "形式", + "ui.equation_note": "變數為純數字:sin(t) 以弧度計算。沒有單位的結果會使用設計的單位。", + "ui.equation_parametric": "參數式", + "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.exportDxf": "匯出 DXF…", @@ -3760,6 +4194,7 @@ "ui.heading_design": "設計", "ui.heading_draft": "拔模", "ui.heading_edit": "編輯", + "ui.heading_equation_curve": "方程式曲線", "ui.heading_extrude": "擠出", "ui.heading_fillet": "圓角", "ui.heading_finish_editing": "完成編輯", @@ -3851,6 +4286,10 @@ "ui.import_ellipsis": "匯入…", "ui.infinite": "無限", "ui.insert": "插入", + "ui.insert_design.browse": "瀏覽…", + "ui.insert_design.open_designs": "已開啟的設計", + "ui.insert_design.saved_file": "{name} 有未儲存的變更,因此插入的是其已儲存的檔案。請儲存後執行「更新連結」以帶入變更。", + "ui.insert_design.unsaved": "未儲存的變更", "ui.inspect": "檢查", "ui.intersect": "相交", "ui.isolate": "隔離", @@ -3994,12 +4433,29 @@ "ui.polygon": "多邊形", "ui.preferences": "偏好設定", "ui.press_a_key": "按一個鍵…", + "ui.print_face_cleared": "{body}:已清除列印底面", + "ui.print_face_input": "列印底面", + "ui.print_face_one_body": "列印底面屬於單一實體:請單獨傳送該實體以變更它", + "ui.print_face_pick": "選取實體的一個平面,將其設為列印底面", + "ui.print_face_set": "{body}:已設定列印底面;傳送到切片軟體時實體會放在此面上", "ui.profile": "輪廓", "ui.profile_plane": "輪廓平面", "ui.profiles": "輪廓", "ui.project": "投影", "ui.project_face_edges_sketch": "將面的邊投影到新草圖", "ui.properties": "屬性", + "ui.properties.copy_matrix": "複製矩陣", + "ui.properties.first_moments": "一次矩", + "ui.properties.inertia": "慣性矩", + "ui.properties.inertia_note": "相對於質心,以世界座標軸表示(g·{unit}²)", + "ui.properties.inertia_unavailable": "無法使用:某個實體的網格未封閉。", + "ui.properties.inertia_x": "Ixx, Ixy, Ixz", + "ui.properties.inertia_y": "Iyx, Iyy, Iyz", + "ui.properties.inertia_z": "Izx, Izy, Izz", + "ui.properties.measuring": "正在測量…", + "ui.properties.measuring_failed": "測量在完成前已停止。", + "ui.properties.principal_axis": "主軸 {number}", + "ui.properties.principal_moments": "主慣性矩", "ui.pull_direction": "拔模方向", "ui.quality": "品質", "ui.quantity": "數量", @@ -4045,6 +4501,7 @@ "ui.revolve": "旋轉", "ui.ribbon_exit": "結束", "ui.ribbon_labels": "顯示功能區圖示下的文字", + "ui.ribbon_menus_skip_pinned": "下拉選單中不列出已固定的命令", "ui.ribbon_pin_footer": "固定的命令會顯示在功能區中。將指標停留在某列上即可固定或取消固定。", "ui.ribbon_workspace": "工作區", "ui.right": "右視", @@ -4099,7 +4556,11 @@ "ui.sketch": "草圖", "ui.sketch_dimension": "草圖尺寸", "ui.sketch_grid": "草圖網格", + "ui.sketch_intersect_geometry": "幾何", + "ui.sketch_intersect_projection_link": "投影連結", + "ui.sketch_intersect_selection_filter": "選擇篩選", "ui.sketch_modifiers": "草圖修飾鍵", + "ui.sketch_project_geometry": "幾何", "ui.sketch_scale": "草圖比例", "ui.sketches": "草圖", "ui.slice": "切片", @@ -4150,6 +4611,7 @@ "ui.tab_manage": "管理", "ui.tab_sheet_metal": "鈑金", "ui.tab_view": "檢視", + "ui.tab_weldments": "焊件", "ui.tangent": "相切", "ui.taper_angle": "錐度角", "ui.tapped": "攻牙", @@ -4165,6 +4627,7 @@ "ui.tip_angle": "尖端角度", "ui.to_object": "到物件", "ui.tool_direction": "刀具方向", + "ui.toolbar_tabs_hidden": "所有工具列索引標籤都已隱藏。按一下 + 或在此按右鍵以顯示一個索引標籤。", "ui.top": "上視", "ui.top_diameter": "頂部直徑", "ui.top_radius": "頂部半徑", @@ -4202,6 +4665,8 @@ "ui.view_centre": "檢視中心", "ui.viewport_status_clear": "關閉 {name}", "ui.viewport_status_clear_hint": "關閉 {name} · Esc", + "ui.viewport_status_edges": "{count} 條邊", + "ui.viewport_status_from": "從 {direction}", "ui.viewport_status_key": "顯示 {name} 的圖例", "ui.viewport_status_minimum_radius": "最小半徑", "ui.viewport_status_pairs": "{count} 對", @@ -4211,6 +4676,7 @@ "ui.visual_shaded_with_edges": "含邊線的著色", "ui.visual_style": "視覺樣式", "ui.visual_wireframe": "線框", + "ui.wheel_zoom_speed": "滑鼠滾輪縮放速度", "ui.width": "寬度", "ui.window_system": "視窗系統", "ui.window_system_auto": "自動", @@ -4241,7 +4707,9 @@ "update.button": "更新", "update.button_version": "更新到 {version}", "update.check_automatically": "自動檢查更新", + "update.check_for_updates": "檢查更新", "update.check_now": "立即檢查", + "update.check_unavailable": "此安裝版本無法檢查更新。", "update.checking": "正在檢查…", "update.copy": "複製", "update.dmg_opened": "磁碟映像已驗證並開啟:將 SolidCraft 拖到「應用程式」取代舊版本,然後重新啟動 SolidCraft。", @@ -4255,13 +4723,18 @@ "update.installed": "SolidCraft {version} 已安裝。重新啟動後生效。", "update.installer_started": "SolidCraft 關閉後將啟動安裝程式。", "update.later": "稍後提醒", + "update.managed": "有可用更新:請在 {manager} 中更新 SolidCraft", "update.no_notes": "此版本沒有發佈說明。", "update.package_hint": "此副本來自 {kind}。下載新套件後用以下命令安裝:", "update.prefs_dev": "這是開發版本({version}),不檢查更新。", "update.prefs_flatpak": "此副本({version})由 Flatpak 更新:執行 {command} 或使用軟體中心。SolidCraft 不會自行檢查。", "update.prefs_label": "更新", + "update.prefs_managed": "目前為 SolidCraft {version},由 {manager} 管理。檢查時只向 GitHub 查詢最新版本,不傳送其他任何資料;請在 {manager} 中更新 SolidCraft。", "update.prefs_note": "目前為 SolidCraft {version}({kind})。檢查時只向 GitHub 查詢最新版本,不傳送其他任何資料。", "update.prefs_web": "重新載入頁面以取得最新的網頁應用程式。如果網路無法使用,或更新無法及時下載完成,則會改為開啟已儲存的版本;進行中的下載會繼續,供下次重新載入時使用。", + "update.refresh_checking": "正在檢查最新版本…", + "update.refresh_fallback": "無法檢查是否有更新的版本;將更新至先前找到的 {version}。", + "update.refresh_selected": "正在更新至 {version}。", "update.release_page": "開啟發佈頁面", "update.restart": "立即重新啟動", "update.restart_later": "稍後", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 22d7310925..860836c2b2 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.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": "专用操作", @@ -775,6 +778,8 @@ "at the face": "在面上", "bom.group_children": "子零件并入所属零件的行", "browser.body_name": "实体{number}", + "browser.calibrate_click_a": "点击图像选取点 A,或输入其坐标。", + "browser.calibrate_click_b": "点击图像选取点 B,或输入其坐标。", "browser.calibrate_title": "校准:{name}", "browser.capture_many": "{count} 个组件已移动,但尚未捕获其位置。", "browser.capture_note": "“捕获位置”保留新位置;“恢复位置”还原原先的位置。", @@ -817,6 +822,7 @@ "cmd.appearance_pick_up": "拾取外观", "cmd.appearance_replace": "替换外观", "cmd.assembly_bom": "物料清单", + "cmd.body.print_face": "打印底面", "cmd.body_rename": "重命名实体", "cmd.browser_collapse_all": "全部折叠", "cmd.browser_expand_all": "全部展开", @@ -1101,6 +1107,16 @@ "cmd.sheet_refold": "重新折弯", "cmd.sheet_rules": "列出钣金规则", "cmd.sheet_unfold": "展开", + "cmd.sketch.construction_line": "构造直线", + "cmd.sketch.construction_line.angle": "指定角度构造直线", + "cmd.sketch.construction_line.bisector": "角平分线", + "cmd.sketch.construction_line.horizontal": "水平构造直线", + "cmd.sketch.construction_line.parallel": "平行构造直线", + "cmd.sketch.construction_line.perpendicular": "垂直构造直线", + "cmd.sketch.construction_line.tangent_at": "曲线上点处的切线", + "cmd.sketch.construction_line.tangent_external": "过外部点的切线", + "cmd.sketch.construction_line.tangent_two": "公切线", + "cmd.sketch.construction_line.vertical": "竖直构造直线", "cmd.sketch_arc3d": "三维圆弧", "cmd.sketch_arc_center_point": "中心点圆弧", "cmd.sketch_arc_tangent": "相切圆弧", @@ -1389,7 +1405,18 @@ "help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。", "help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。", "help.pointcloud.insert": "将 PLY、XYZ 或 PTS 扫描作为点云插入到模型旁。根据测量数据建模时,可将其用作参考。", + "help.sketch.construction_line": "创建经过两点的无限构造直线,用于辅助绘制草图而不分割轮廓。", + "help.sketch.construction_line.angle": "创建与草图X轴成指定角度的无限构造直线。选择一点后输入角度或选择方向。", + "help.sketch.construction_line.bisector": "选择两条相交的草图直线,再选择所需的角平分线。结果随原直线的编辑而变化。", + "help.sketch.construction_line.horizontal": "创建经过指定点的无限水平构造直线。后续编辑时保持水平约束。", + "help.sketch.construction_line.parallel": "选择草图直线,然后放置与其平行的无限构造直线。右键单击可更换参照。", + "help.sketch.construction_line.perpendicular": "选择草图直线,然后放置与其垂直的无限构造直线。右键单击可更换参照。", + "help.sketch.construction_line.tangent_at": "在圆或圆弧上的点处,或开放样条曲线、圆锥曲线的端点处创建切线。先选曲线,再选接点。", + "help.sketch.construction_line.tangent_external": "依次选择圆或圆弧、外部点和所需切线。预览仅显示接点位于已绘圆弧上的解。", + "help.sketch.construction_line.tangent_two": "选择两个圆或圆弧,再从预览中选择公切线。接点必须位于已绘曲线上。", + "help.sketch.construction_line.vertical": "创建经过指定点的无限竖直构造直线。后续编辑时保持竖直约束。", "help.sketch.equation_curve": "在一个范围内按公式绘制曲线:x(t) 和 y(t),或 y = f(x)。公式可以使用参数,曲线随参数变化。适用于正弦波、螺旋线或齿轮的渐开线。", + "help.sketch.inspect": "打开当前草图的报告:显示剩余的自由度以及是否已完全约束。在完成草图之前,用它找出仍然可以移动的部分。", "help.solid.align": "移动所选实体,使其上的点、面或平面与别处的点、面或平面对齐。用于把一个零件贴靠到另一个零件上,而无需测量距离。", "help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。", "help.solid.combine": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。", @@ -1592,6 +1619,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} 秒前", @@ -1744,6 +1775,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": "内径", @@ -1766,6 +1802,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", @@ -1818,6 +1856,8 @@ "prefs.bottom_centre": "底部居中", "prefs.bottom_right": "右下角", "prefs.browser": "浏览器", + "prefs.browser_eye_left": "显示切换位于折叠箭头旁", + "prefs.browser_eye_left_note": "浏览器各行的显示切换像以前一样位于折叠箭头旁,而不是在行右端的一列中。", "prefs.browser_reveal_on_hover": "悬停时显示", "prefs.browser_show_note": "“显示”让浏览器始终留在屏幕上。“悬停时显示”会将其隐藏,直到指针移到它的位置或其左边缘的细标签上;指针在其中或其菜单打开时,它保持打开。", "prefs.browser_shown": "显示", @@ -1868,6 +1908,8 @@ "prefs.graphics_card_restart": "重启 SolidCraft 后生效。WGPU_ADAPTER_NAME 优先于此选择。", "prefs.graphics_note": "在速度与平滑度之间取舍。每个选项均以示例展示效果。", "prefs.graphics_presets_note": "预设会同时设置以下选项。", + "prefs.hide_constraint_glyphs_zoomed_out": "缩小时隐藏约束符号", + "prefs.hide_constraint_glyphs_zoomed_out_note": "草图缩小到一定程度时隐藏约束符号,以免遮挡草图。关闭:任意缩放比例下符号仍保持固定屏幕大小。", "prefs.history": "历史", "prefs.history_origin_bodies": "在顶部显示原点和实体", "prefs.history_origin_bodies_note": "左侧或浏览器下方的历史以活动组件的实体文件夹和原点开头,并带有与浏览器相同的显示切换和菜单。底部的历史保持不变。", @@ -1906,6 +1948,9 @@ "prefs.max_file_size_step": "STEP 和导入文件", "prefs.max_file_size_web": "网页版可打开最大 512 MB 的 STEP 和导入文件,以及最大 256 MB 的设计:浏览器标签页无法容纳更大的文件。桌面版可提高这些上限。", "prefs.megabytes": "MB", + "prefs.menu_grid": "大图标", + "prefs.menu_list": "列表", + "prefs.menus": "菜单", "prefs.minutes": "分钟", "prefs.minutes_suffix": " min", "prefs.mouse": "鼠标与触控板", @@ -1925,10 +1970,16 @@ "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": "视场角", "prefs.placement": "放置", "prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点", "prefs.preview_choices": "所选设置预览", + "prefs.print_face_highlight": "打印底面高亮", + "prefs.print_face_highlight_tip": "视图中实体打印底面的着色和轮廓强度。0 % 不显示高亮。", "prefs.profile_active": "当前配置文件", "prefs.profile_applied": "已应用配置文件。", "prefs.profile_changed": "保存配置文件后设置已更改。“保存”会将其写入配置文件。", @@ -1950,6 +2001,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} 份", @@ -1963,6 +2016,8 @@ "prefs.search_empty": "没有匹配的设置", "prefs.select": "选择", "prefs.select_one_item": "选择一个项目", + "prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体", + "prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。", "prefs.selecting": "选择", "prefs.shift": "Shift", "prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择", @@ -1974,11 +2029,14 @@ "prefs.sketch_align_points_note": "绘制时,点可同时与一个草图点水平对齐、与另一个草图点竖直对齐,并显示虚线参考线;单击后保留两个对齐约束。关闭:仅与上一点对齐,每次一个方向。", "prefs.sketch_conflict_colour": "以冲突颜色显示冲突的几何图形", "prefs.sketch_conflict_colour_note": "草图约束冲突时,不仅失败约束的符号或尺寸,该约束所约束的曲线和点也以冲突颜色绘制。关闭:它们保持平常的颜色。", + "prefs.sketch_construction_lines": "扩展构造直线工具", "prefs.sketch_exit_view": "草图结束时的视图", "prefs.sketch_exit_view.keep": "保持当前视图", "prefs.sketch_exit_view.previous": "返回之前的视图", "prefs.sketch_exit_view.unless_rotated": "未旋转时返回", "prefs.sketch_exit_view_note": "“完成草图”、取消或连按两次 Esc 后视图的去向。", + "prefs.sketch_open_look_at": "打开草图时转向正对草图", + "prefs.sketch_open_look_at_note": "关闭:创建或编辑草图时视图保持不变。需要时用“朝向”正对草图。", "prefs.sketch_recent_snap": "自动吸附到最近的圆心和中点", "prefs.sketch_recent_snap_note": "绘图时,将光标吸附到最近两个圆心或直线边中点的辅助线相交处。关闭:辅助线仅作显示,光标不会移动。", "prefs.sketch_selection": "草图与选择", @@ -2038,13 +2096,21 @@ "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": "打开对话框或草图时,视图右上角的勾号和叉号与对话框的确定和取消作用相同,或完成和取消草图。视图导航器会下移到它们下方。", "prefs.viewport": "视口", + "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.wheel": "滚轮", "prefs.wheel_trackpad": "滚轮与触控板", "prefs.workspace": "工作区", @@ -2054,6 +2120,10 @@ "properties.title": "属性:{name}", "recovery.days_ago": "{count} 天前", "recovery.detail": "{file} · {features} 个特征 · 自动保存于 {when}", + "recovery.discard_all": "全部放弃", + "recovery.discard_all_detail": "此操作无法撤消。", + "recovery.discard_all_title": "放弃 {count} 个自动保存?", + "recovery.discard_all_title_one": "放弃 1 个自动保存?", "recovery.hours_ago": "{count} 小时前", "recovery.just_now": "刚刚", "recovery.min_ago": "{count} 分钟前", @@ -2919,7 +2989,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} 和“{profile}”CPU 资源配置,或选择更小的尺寸。", "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": "查看图像", @@ -3132,6 +3204,7 @@ "render.size_custom": "自定义…", "render.size_full_hd": "全高清", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · 需要“{profile}”", "render.size_presets": "尺寸预设", "render.size_presets_pending": "随输出编辑器推出", "render.size_preview": "预览", @@ -3419,6 +3492,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 框顶部", @@ -3455,6 +3546,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": "文字沿路径排列:由曲线放置", @@ -3595,6 +3687,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 鼠标速度", @@ -3623,6 +3717,8 @@ "ui.3d_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。", "ui.3d_mouse_switch_cap": "按回中类型校准", "ui.3d_mouse_switch_trackball": "按轨迹球校准", + "ui.3d_mouse_use_direct": "使用直接驱动", + "ui.3d_mouse_use_direct_tip": "直接读取 3D 鼠标(hidraw),不经过 spacenavd。", "ui.3d_mouse_view_axis": "视图轴", "ui.3d_mouse_view_off": "关闭", "ui.3d_mouse_view_off_tip": "3D 鼠标驱动程序的配置文件关闭了此轴,因此它的动作不会移动视图。在此选择另一个轴的动作会将两者互换,该动作将不再移动视图。", @@ -3756,6 +3852,8 @@ "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": "保存", @@ -3939,6 +4037,7 @@ "ui.circle": "圆", "ui.circular_pattern": "圆形阵列", "ui.clear": "清除", + "ui.clear_print_face": "清除打印底面", "ui.clear_recent": "清除最近", "ui.clear_the_selection": "清除选择", "ui.click_selects_whole_bodies": "单击选择整个实体", @@ -3973,6 +4072,15 @@ "ui.constant": "恒定", "ui.constraints_conflict": "约束冲突", "ui.construction": "构造", + "ui.construction.angle_hint": "构造直线:单击一点,然后输入角度或选择方向", + "ui.construction.bisector_hint": "角平分线:选择两条相交直线,再选择所需解", + "ui.construction.common_hint": "公切线:选择两个圆/圆弧,再选择所需解", + "ui.construction.contact_hint": "切线:先选圆/圆弧或开放样条曲线/圆锥曲线,再选接点(自由曲线仅支持端点)", + "ui.construction.external_hint": "切线:依次选择圆或圆弧、外部点和所需解", + "ui.construction.free_hint": "构造直线:单击两点;右键单击重新开始", + "ui.construction.point_hint": "构造直线:单击要经过的点", + "ui.construction.reference_hint": "构造直线:先选直线,再选一点;右键单击更换参照", + "ui.construction.ribbon_label": "辅助线", "ui.context_menu": "上下文菜单", "ui.continuity_g0": "G0 折痕", "ui.continuity_g1": "G1 相切", @@ -4362,12 +4470,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": "文件单位", @@ -4376,6 +4489,11 @@ "ui.polygon": "多边形", "ui.preferences": "首选项", "ui.press_a_key": "按一个键…", + "ui.print_face_cleared": "{body}:已清除打印底面", + "ui.print_face_input": "打印底面", + "ui.print_face_one_body": "打印底面属于单个实体:请单独发送该实体以更改它", + "ui.print_face_pick": "选择实体的一个平面,将其设为打印底面", + "ui.print_face_set": "{body}:已设置打印底面;发送到切片软件时实体将放在此面上", "ui.profile": "轮廓", "ui.profile_plane": "轮廓平面", "ui.profiles": "轮廓", @@ -4439,6 +4557,7 @@ "ui.revolve": "旋转", "ui.ribbon_exit": "退出", "ui.ribbon_labels": "显示功能区图标下的文字", + "ui.ribbon_menus_skip_pinned": "下拉菜单中不再列出已固定的命令", "ui.ribbon_pin_footer": "固定的命令显示在功能区中。将指针悬停在行上可固定或取消固定。", "ui.ribbon_workspace": "工作区", "ui.right": "右视", @@ -4457,6 +4576,7 @@ "ui.selectOther": "选择其他…", "ui.selectTangentChain": "选择相切链", "ui.select_all": "全选", + "ui.select_components_by_face": "选择组件(单击面即选择其所属组件)", "ui.selection": "选择", "ui.selection_modifiers": "选择修饰键", "ui.selection_shift_add_only": "Shift+点击仅添加、不取消选择", @@ -4498,6 +4618,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": "切片", @@ -4602,10 +4727,19 @@ "ui.view_centre": "视图中心", "ui.viewport_status_clear": "关闭{name}", "ui.viewport_status_clear_hint": "关闭{name} · Esc", + "ui.viewport_status_edges": "{count} 条边", + "ui.viewport_status_from": "从 {direction}", "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": "带边着色", @@ -4642,7 +4776,9 @@ "update.button": "更新", "update.button_version": "更新到 {version}", "update.check_automatically": "自动检查更新", + "update.check_for_updates": "检查更新", "update.check_now": "立即检查", + "update.check_unavailable": "此安装版本无法检查更新。", "update.checking": "正在检查…", "update.copy": "复制", "update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。", diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 3e323724e9..4715d9edfd 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -243,6 +243,44 @@ fn pushed(z: f32) -> f32 { return clamp(z - select(-push, push, reversed()), 0.0, 1.0); } +/// A far wall seen through the cut where another body's face lying on it was drawn in front of +/// it (a shaft in its bore, #9407): its cap colour again, at its depth pulled toward the eye by +/// `touch_depth`, so it wins over that face, which is faceted another way. +@fragment +fn fs_solid_touch(i: TOut, @builtin(front_facing) front: bool) -> CutOut { + var o: CutOut; + // Derivatives (in `touch_depth`) first, before `solid` may discard. + o.depth = touch_depth(i.wp, i.pos.z); + o.c = solid(i, front); + return o; +} + +@fragment +fn fs_solid_touch_flat(i: FOut, @builtin(front_facing) front: bool) -> CutOut { + let t = flat_fragment(i, front); + var o: CutOut; + o.depth = touch_depth(t.wp, t.pos.z); + o.c = solid(t, front); + return o; +} + +/// The depth of `wp` (depth `z`) pulled toward the eye by what may part two faceted copies of +/// one surface: 3 pixels' width along the view ray (display refinement keeps a facet's sag about +/// a pixel) and 2 pixels of the face's own depth slope (more where it turns from the eye). +fn touch_depth(wp: vec3, z: f32) -> f32 { + let px = min(length(dpdx(wp)), length(dpdy(wp))); + let slope = max(abs(dpdx(z)), abs(dpdy(z))); + var dir = -u.back.xyz; + if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; } + let len = length(dir); + if (len < 1e-12) { return z; } + let c = u.vp * vec4(wp - dir * (3.0 * px / len), 1.0); + if (c.w <= 0.0) { return z; } + let pulled = c.z / c.w; + if (reversed()) { return clamp(max(pulled, z) + 2.0 * slope, 0.0, 1.0); } + return clamp(min(pulled, z) - 2.0 * slope, 0.0, 1.0); +} + /// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut), /// never behind the wall itself (`z`). fn cap_depth(wp: vec3, z: f32) -> f32 { @@ -697,38 +735,96 @@ fn fs_line_all(i: LOut) -> @location(0) vec4 { return out_color(i.c); } -// Point clouds: each point is a round dot a few pixels across, facing the screen, depth-tested -// and written like the model's faces, so the model hides the points behind it and they hide it. -// The colour's alpha carries the dot's size (eighths of a pixel). +// Point clouds (`view/clouds.rs`): each point is a round dot a few pixels across, facing the +// screen, depth-tested and written like the model's faces, so the model hides the points behind +// it and they hide it. The cloud's style colours them by their own colour, a ramp or a palette, +// and shows only those whose filtered values lie in its ranges or sets. -struct POut { +struct Cloud { + style: vec4, // x: colour (0 own, 1 ramp, 2 palette), y: dot size (px), z, w: ramp range + filters: array, 4>, // x: kind (0 off, 1 range y…z, 2 whole values y + i for the bits i of w) +}; +@group(1) @binding(3) var cloud: Cloud; + +struct CIn { + @location(0) p: vec3, + @location(1) v: f32, + @location(2) f: vec4, + @location(3) rgb: vec4, +}; + +struct COut { @builtin(position) pos: vec4, - @location(0) c: vec4, - @location(1) wp: vec3, - @location(2) at: vec2, + @location(0) wp: vec3, + @location(1) v: f32, + @location(2) f: vec4, + @location(3) rgb: vec3, + @location(4) at: vec2, // the place on its disc + @location(5) @interpolate(flat) has: u32, }; @vertex -fn vs_point(@builtin(vertex_index) vi: u32, @location(0) p: vec3, @location(1) c: vec4) -> POut { +fn vs_cloud_dot(@builtin(vertex_index) vi: u32, i: CIn) -> COut { var xs = array(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0); var ys = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); let at = vec2(xs[vi], ys[vi]); - let size = max(c.a * 255.0 / 8.0, 1.0); - let centre = u.vp * vec4(p, 1.0); - let half = u.screen.xy * 0.5; - var o: POut; - o.pos = vec4(centre.xy + at * size * 0.5 / half * centre.w, centre.z, centre.w); + let size = max(cloud.style.y, 1.0); + let centre = u.vp * vec4(i.p, 1.0); + var o: COut; + o.pos = vec4(centre.xy + at * size / u.screen.xy * centre.w, centre.z, centre.w); // Behind the eye: nothing. if (near_z(centre) < 0.0) { o.pos = vec4(0.0, 0.0, 0.0, -1.0); } - o.c = vec4(c.rgb, 1.0); - o.wp = p; + o.wp = i.p; + o.v = i.v; + o.f = i.f; + o.rgb = i.rgb.rgb; + o.has = u32(round(i.rgb.a * 255.0)); // Dots under two pixels are squares: a disc that small loses its centre pixel. o.at = at * select(1.0, 0.0, size < 2.0); return o; } -@fragment -fn fs_point(i: POut) -> @location(0) vec4 { - if (clipped(i.wp) || dot(i.at, i.at) > 1.0) { discard; } - return out_color(i.c); +/// Whether filter `f` keeps the value `v`. +fn cloud_keeps(f: vec4, v: f32) -> bool { + if (f.x < 0.5) { return true; } + if (f.x < 1.5) { return v >= f.y && v <= f.z; } + let k = i32(round(v)) - i32(round(f.y)); + return k >= 0 && k < 32 && ((bitcast(f.w) >> u32(k)) & 1u) != 0u; +} + +fn cloud_ramp(t: f32) -> vec3 { + var stops = array, 5>( + vec3(40.0, 70.0, 200.0), vec3(30.0, 170.0, 230.0), vec3(60.0, 200.0, 90.0), + vec3(240.0, 220.0, 60.0), vec3(230.0, 70.0, 50.0)); + let k = clamp(t, 0.0, 1.0) * 4.0; + let j = min(u32(k), 3u); + return mix(stops[j], stops[j + 1u], k - f32(j)) / 255.0; +} + +fn cloud_palette(v: f32) -> vec3 { + var colours = array, 10>( + vec3(240.0, 140.0, 40.0), vec3(245.0, 205.0, 65.0), vec3(230.0, 65.0, 65.0), + vec3(165.0, 75.0, 190.0), vec3(65.0, 190.0, 115.0), vec3(75.0, 150.0, 220.0), + vec3(240.0, 120.0, 180.0), vec3(140.0, 105.0, 75.0), vec3(120.0, 215.0, 215.0), + vec3(190.0, 190.0, 80.0)); + if (v < -0.5) { return vec3(150.0, 160.0, 176.0) / 255.0; } + return colours[u32(round(v)) % 10u] / 255.0; +} + + +@fragment +fn fs_cloud(o: COut) -> @location(0) vec4 { + var out = clipped(o.wp) || dot(o.at, o.at) > 1.0; + for (var k = 0u; k < 4u; k++) { + out = out || (((o.has >> k) & 1u) != 0u && !cloud_keeps(cloud.filters[k], o.f[k])); + } + if (out) { discard; } + var c = o.rgb; + let mode = cloud.style.x; + if ((o.has & 16u) != 0u && mode > 1.5) { + c = cloud_palette(o.v); + } else if ((o.has & 16u) != 0u && mode > 0.5) { + c = cloud_ramp((o.v - cloud.style.z) / max(cloud.style.w - cloud.style.z, 1e-12)); + } + return out_color(vec4(c, 1.0)); } diff --git a/crates/doc/src/browser_tree.rs b/crates/doc/src/browser_tree.rs new file mode 100644 index 0000000000..5d10a4b391 --- /dev/null +++ b/crates/doc/src/browser_tree.rs @@ -0,0 +1,182 @@ +//! The browser's groups as a tree: how deep a group sits, who holds whom, and pruning what a +//! saved design says about the browser down to what still exists. +use crate::document::{BROWSER_FOLDERS, BrowserEntry, BrowserGroup, Document, MAX_GROUP_NESTING}; +use std::collections::{BTreeMap, BTreeSet}; + +impl Document { + pub fn browser_group(&self, id: u64) -> Option<&BrowserGroup> { + self.browser_groups.iter().find(|g| g.id == id) + } + + /// The level of a group: 1 for a top-level group, 2 for a child of one, and so on. A chain + /// that loops or runs past the limit counts as one level too deep. + pub fn group_level(&self, id: u64) -> usize { + let mut level = 0; + let mut at = Some(id); + while let Some(g) = at { + level += 1; + if level > MAX_GROUP_NESTING { + return level; + } + at = self.browser_group(g).and_then(|g| g.parent); + } + level + } + + /// Whether `ancestor` is `id` or holds it, at any depth. + pub fn group_within(&self, id: u64, ancestor: u64) -> bool { + let mut at = Some(id); + for _ in 0..=MAX_GROUP_NESTING { + match at { + Some(g) if g == ancestor => return true, + Some(g) => at = self.browser_group(g).and_then(|g| g.parent), + None => return false, + } + } + false + } + + /// How many levels the group and the groups under it take: 1 for a group with no child group. + pub fn group_height(&self, id: u64) -> usize { + fn height(d: &Document, id: u64, left: usize) -> usize { + if left == 0 { + return 1; + } + 1 + d.browser_groups.iter().filter(|g| g.parent == Some(id)).map(|g| height(d, g.id, left - 1)).max().unwrap_or(0) + } + height(self, id, MAX_GROUP_NESTING + 1) + } + + /// The entries of a holder's list in display order: a group's contents, or the folder's top + /// level when `holder` is `None`. Groups the list names come where it names them. The child + /// groups it does not name (in creation order) go just after the last group it names, or + /// first when it names none, so they sit above the items as they always have. Entries for + /// groups that are not the holder's children are dropped. + pub fn ordered_entries(&self, component: u64, folder: &str, holder: Option) -> Vec { + let listed: &[BrowserEntry] = match holder { + Some(g) => self.browser_group(g).map_or(&[], |g| &g.items), + None => self.browser_order.get(&format!("{component}/{folder}")).map_or(&[], Vec::as_slice), + }; + let mine: Vec = + self.browser_groups.iter().filter(|g| g.component == component && g.folder == folder && g.parent == holder).map(|g| g.id).collect(); + let mut out: Vec = Vec::with_capacity(listed.len() + mine.len()); + for e in listed { + match e { + BrowserEntry::Group(id) if !mine.contains(id) || out.contains(e) => {} + _ => out.push(e.clone()), + } + } + let at = out.iter().rposition(|e| matches!(e, BrowserEntry::Group(_))).map_or(0, |i| i + 1); + let unlisted: Vec = mine.iter().map(|id| BrowserEntry::Group(*id)).filter(|e| !out.contains(e)).collect(); + out.splice(at..at, unlisted); + out + } + + /// The items in a group and in the groups inside it. + pub fn group_item_keys(&self, id: u64) -> Vec { + let inside = self.browser_groups.iter().filter(|g| self.group_within(g.id, id)); + inside.flat_map(|g| g.item_keys().map(str::to_string)).collect() + } + + /// The group holding the item directly, in a component's folder. + pub fn group_of_item(&self, component: u64, folder: &str, key: &str) -> Option<&BrowserGroup> { + self.browser_groups.iter().find(|g| g.component == component && g.folder == folder && g.has_item(key)) + } + + /// Only the browser's groups and order changed (a group made, nested, moved into, ordered): + /// nothing is evaluated and no placement moves. The same whole-Document proof as + /// [`Document::differs_only_in_render`]; `next_id` may differ, as a new group takes an id. + pub fn differs_only_in_browser(&self, previous: &Document) -> bool { + if self.browser_groups == previous.browser_groups && self.browser_order == previous.browser_order { + return false; + } + let mut normalized = previous.clone(); + normalized.browser_groups.clone_from(&self.browser_groups); + normalized.browser_order.clone_from(&self.browser_order); + normalized.next_id = self.next_id; + *self == normalized + } + + /// What [`Document::browser_pruned`] depends on, cheaply: it can only find something to drop + /// after a feature, placement, component or group was added or removed. Ids are never + /// reused, so a removal followed by an addition still moves `next_id`. + pub fn browser_stamp(&self) -> [u64; 6] { + let n = |len: usize| u64::try_from(len).unwrap_or(u64::MAX); + [ + self.next_id, + n(self.features.len()), + n(self.occurrences.len()), + n(self.components.len()), + n(self.browser_groups.len()), + n(self.browser_order.len()), + ] + } + + /// The groups and order lists with what they name that is gone dropped, or `None` when + /// nothing is: groups of a missing component or folder, a parent that is missing, elsewhere + /// or loops, entries for groups and sketches, construction items and placements that no + /// longer exist, repeats, and order lists of missing components. Body names are left alone: + /// only an evaluated model knows them. + pub fn browser_pruned(&self) -> Option<(Vec, BTreeMap>)> { + if self.browser_groups.is_empty() && self.browser_order.is_empty() { + return None; + } + let components: BTreeSet = self.components.iter().map(|c| c.id).collect(); + let features: BTreeSet = self.features.iter().map(|f| f.id).collect(); + let placed_in: BTreeMap = self.occurrences.iter().map(|o| (o.id, o.parent)).collect(); + let component_exists = |c: u64| c == 0 || components.contains(&c); + let placed = |component: u64, key: &str| key.parse::().ok().and_then(|o| placed_in.get(&o)) == Some(&component); + // Body names cannot be checked here; the other folders hold feature ids. + let item_exists = |folder: &str, component: u64, key: &str| match folder { + "bodies" => true, + "components" => placed(component, key), + _ => key.parse::().is_ok_and(|id| features.contains(&id)), + }; + let mut groups = self.browser_groups.clone(); + let mut order = self.browser_order.clone(); + groups.retain(|g| component_exists(g.component) && BROWSER_FOLDERS.contains(&g.folder.as_str())); + let known: BTreeMap = groups.iter().map(|g| (g.id, (g.component, g.folder.clone()))).collect(); + let parents: BTreeMap> = groups + .iter() + .map(|g| (g.id, g.parent.filter(|p| *p != g.id && known.get(p).is_some_and(|(c, f)| *c == g.component && *f == g.folder)))) + .collect(); + // A loop, or a chain too deep, is cut at the group that makes it. + let too_deep = |id: u64| std::iter::successors(Some(id), |g| parents.get(g).copied().flatten()).nth(MAX_GROUP_NESTING).is_some(); + let kept: BTreeMap> = parents.iter().map(|(id, p)| (*id, if too_deep(*id) { None } else { *p })).collect(); + for g in &mut groups { + g.parent = kept[&g.id]; + } + let mut taken: BTreeSet<(u64, String, String)> = BTreeSet::new(); + for g in &mut groups { + let (component, folder, id) = (g.component, g.folder.clone(), g.id); + let mut seen = BTreeSet::new(); + g.items.retain(|e| match e { + BrowserEntry::Group(c) => kept.get(c) == Some(&Some(id)) && seen.insert(*c), + BrowserEntry::Item(k) => item_exists(&folder, component, k) && taken.insert((component, folder.clone(), k.clone())), + }); + } + let top = |c: u64, folder: &str, id: u64| kept.get(&id) == Some(&None) && known.get(&id).is_some_and(|(kc, kf)| *kc == c && kf == folder); + order.retain(|key, list| { + let Some((c, folder)) = key.split_once('/') else { return false }; + let Some(c) = c.parse::().ok().filter(|c| component_exists(*c)) else { return false }; + if !BROWSER_FOLDERS.contains(&folder) { + return false; + } + let mut seen = BTreeSet::new(); + list.retain(|e| match e { + BrowserEntry::Group(id) => top(c, folder, *id) && seen.insert(e.clone()), + BrowserEntry::Item(k) => item_exists(folder, c, k) && seen.insert(e.clone()), + }); + !list.is_empty() + }); + (groups != self.browser_groups || order != self.browser_order).then_some((groups, order)) + } + + /// Put placements of one component in the order the browser shows them: the saved + /// `components` order, then the rest as they were. + pub fn sort_placements(&self, parent: u64, ids: &mut [u64]) { + let Some(order) = self.browser_order.get(&format!("{parent}/components")) else { return }; + let rank: BTreeMap<&str, usize> = order.iter().enumerate().filter_map(|(i, e)| Some((e.as_item()?, i))).collect(); + ids.sort_by_cached_key(|id| rank.get(id.to_string().as_str()).copied().unwrap_or(usize::MAX)); + } +} diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 83cb3b7331..7e1b1b9fad 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -176,6 +176,8 @@ pub(crate) fn remap_link( remap_link(b, ids, names, bodies, depth + 1); } LinkSource::Plane { name } => mapped_name(name, names), + // A construction axis, by name (#9324); the origin axes are no feature's. + LinkSource::Axis { name } if !matches!(name.as_str(), "X" | "Y" | "Z") => mapped_name(name, names), _ => {} } } diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs index 4ab6a9783a..9d7ba98f0b 100644 --- a/crates/doc/src/construct.rs +++ b/crates/doc/src/construct.rs @@ -344,10 +344,13 @@ fn model_line(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> { _ => Err(bad("the face is not a cylinder, cone or torus")), } } - _ => Err(bad("pick a straight or circular edge, or a cylinder, cone or torus face")), + _ => Err(bad(MODEL_AXIS_REFS)), } } +/// What [`model_axis`] says about a reference that is neither an edge nor a face. +pub const MODEL_AXIS_REFS: &str = "pick a straight or circular edge, or a cylinder, cone or torus face"; + /// The axis a feature turns about when an edge or face of the model is picked for it (a revolve, /// a circular pattern, a plane at an angle), as [`model_line`] finds it. The direction is /// oriented the same way however the edge or surface runs (its largest component positive), so diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 8373cdbc51..cff8ab07ac 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2736,6 +2736,9 @@ pub struct Document { /// Physical material per body (by body name); others use the default. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub materials: std::collections::BTreeMap, + /// The flat face per body (by body name) that a slicer export lays on the print bed. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub print_faces: std::collections::BTreeMap, /// Physical materials defined in this design (`material.create`), next to the built-in ones. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub user_materials: Vec, @@ -2756,9 +2759,11 @@ pub struct Document { /// order; feature ids remain the source of truth when features are renamed or reordered. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub timeline_groups: Vec, - /// Browser item order per component folder (`"/"` → item keys). + /// Browser order per component folder (`"/"` → groups and item keys). + /// The `components` folder orders a component's sub-components (by placement id); it is not + /// drawn as a folder. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub browser_order: std::collections::BTreeMap>, + pub browser_order: std::collections::BTreeMap>, /// Named views: saved cameras. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub named_views: Vec, @@ -2847,8 +2852,69 @@ pub struct BrowserGroup { #[serde(default)] pub component: u64, pub folder: String, + /// The group that holds this one; `None` is the top level of its folder. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + /// The group's items and child groups in display order. A child group missing here still + /// belongs to it (its `parent` says so) and is listed after the ones that are here. #[serde(default)] - pub items: Vec, + pub items: Vec, +} + +/// One line of a browser order list: a group (a number in a saved design) or an item (a string: +/// an item key or body name, so a body named `"1"` never clashes with group 1). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(untagged)] +pub enum BrowserEntry { + Group(u64), + Item(String), +} + +impl From<&str> for BrowserEntry { + fn from(key: &str) -> Self { + BrowserEntry::Item(key.to_string()) + } +} + +impl From for BrowserEntry { + fn from(key: String) -> Self { + BrowserEntry::Item(key) + } +} + +impl BrowserEntry { + pub fn item(key: &str) -> Self { + BrowserEntry::Item(key.to_string()) + } + + pub fn as_item(&self) -> Option<&str> { + match self { + BrowserEntry::Item(k) => Some(k), + BrowserEntry::Group(_) => None, + } + } + + pub fn is_item(&self, key: &str) -> bool { + self.as_item() == Some(key) + } +} + +/// How many groups may sit inside one another (a top-level group is level 1). +pub const MAX_GROUP_NESTING: usize = 8; + +/// The folders a browser group or order list can be for. `components` only orders sub-components. +pub const BROWSER_FOLDERS: [&str; 4] = ["bodies", "sketches", "construction", "components"]; + +impl BrowserGroup { + /// The keys of the items directly in this group, in order. + pub fn item_keys(&self) -> impl Iterator { + self.items.iter().filter_map(BrowserEntry::as_item) + } + + /// Whether this group holds the item directly. + pub fn has_item(&self, key: &str) -> bool { + self.items.iter().any(|e| e.is_item(key)) + } } /// A saved history folder. Folders may nest; each feature or joint belongs to at most one folder. @@ -2982,6 +3048,7 @@ impl Document { body_components: Default::default(), body_offsets: Default::default(), materials: Default::default(), + print_faces: Default::default(), user_materials: Vec::new(), appearances: Default::default(), favorites: Default::default(), @@ -2997,7 +3064,7 @@ impl Document { } /// Rename body `old` to `new` where the design keeps things by body name outside its - /// features: materials, appearances, plastic rules, configuration columns, offsets, components and the + /// features: materials, print faces, appearances, plastic rules, configuration columns, offsets, components and the /// browser's groups and order. (The features are the rename command's to change.) /// /// A name the design numbered (`Body1`) is not used again after the rename: the design's @@ -3013,6 +3080,10 @@ impl Document { if let Some(m) = self.materials.remove(old) { self.materials.insert(new.to_string(), m); } + if let Some(mut face) = self.print_faces.remove(old) { + face.body = new.to_string(); + self.print_faces.insert(new.to_string(), face); + } if let Some(rule) = self.plastic.assigned.remove(old) { self.plastic.assigned.insert(new.to_string(), rule); } @@ -3039,15 +3110,16 @@ impl Document { if let Some(c) = self.body_components.remove(old) { self.body_components.insert(new.to_string(), c); } - for g in self.browser_groups.iter_mut().filter(|g| g.folder == "bodies") { - for k in g.items.iter_mut().filter(|k| *k == old) { - *k = new.to_string(); + let rename = |e: &mut BrowserEntry| { + if e.is_item(old) { + *e = BrowserEntry::item(new); } + }; + for g in self.browser_groups.iter_mut().filter(|g| g.folder == "bodies") { + g.items.iter_mut().for_each(rename); } for (_, v) in self.browser_order.iter_mut().filter(|(k, _)| k.ends_with("/bodies")) { - for k in v.iter_mut().filter(|k| *k == old) { - *k = new.to_string(); - } + v.iter_mut().for_each(rename); } } @@ -3366,10 +3438,14 @@ impl Document { /// Read a design file's JSON (any format this build knows; older ones are upgraded). pub fn from_json(s: &str) -> Result { crate::render_resources::check_json_budget(s)?; - let d = crate::format::read(s)?; + let mut d = crate::format::read(s)?; if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS { return Err(DocError::Invalid("document too large".into())); } + // A design from elsewhere may nest groups too deep, loop, or name things that are gone. + if let Some((groups, order)) = d.browser_pruned() { + (d.browser_groups, d.browser_order) = (groups, order); + } Ok(d) } pub fn to_json(&self) -> String { @@ -4014,7 +4090,7 @@ impl Document { return Err(DocError::Invalid("a text's height must be positive".into())); } if let Some(e) = &exprs.text { - let words = self.eval_words(e).map_err(|error| DocError::Expr(format!("text: {error}")))?; + let words = self.eval_words(e, vals).map_err(|error| DocError::Expr(format!("text: {error}")))?; if words.trim().is_empty() || words.chars().count() > solidcraft_sketch::MAX_TEXT_CHARS { return Err(DocError::Invalid(format!("text: `{e}` gives no words, or too many"))); } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 389e36a58a..62927a487f 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -792,6 +792,11 @@ fn split_piece_of(name: &str) -> Option<&str> { (!n.starts_with('0') && n.parse::().is_ok()).then_some(base) } +/// The warning of a sketch whose profile search was cut short (#6896). +fn profiles_cut_short_note() -> String { + format!("some profiles may be missing: a group of touching curves is cut into more than {} pieces", solidcraft_sketch::MAX_PROFILE_EDGES) +} + /// Add `note` to a feature's warning. fn append_warning(warning: &mut Option, note: String) { *warning = Some(match warning.take() { @@ -2153,7 +2158,14 @@ impl Model { feature_component.entry(f.id).or_insert(f.component); } let watched = crate::progress::begin(doc.features.len()); + // The features that failed so far in this pass, by id: their names and errors (#9075). + let mut failed: std::collections::HashMap = std::collections::HashMap::new(); for (i, f) in doc.features.iter().enumerate() { + if let Some(r) = out.last() + && let Some(e) = &r.error + { + failed.insert(r.id, (r.name.clone(), e.clone())); + } self.placement_local = self.placement_local && placement_reuse::local_geometry(f, &feature_component); let rolled_back = i >= marker; // Retain at most one active result per rolled-back feature, even across several @@ -2213,6 +2225,33 @@ impl Model { if state.reserved_body_names != reserved { Arc::make_mut(&mut state).reserved_body_names.clone_from(&reserved); } + // A sketch the feature is built from failed earlier in this pass: the feature fails naming + // that sketch, with its error, as one using a failed parameter does (#9075), not as a + // sketch that must come earlier in the timeline. Never cached: the cache key holds + // neither the sketch's name nor its error (a rename, another failing value). + let failed_sketch = if failed.is_empty() { + None + } else { + f.kind.input_sketches().into_iter().find_map(|id| failed.get(&id)).map(|(name, e)| format!("sketch `{name}` failed: {e}")) + }; + if let Some(e) = failed_sketch { + recomputed += 1; + reuse = false; + out.push(FeatureResult { + id: f.id, + name: f.name.clone(), + error: Some(e), + warning: None, + boolean_diagnostics: Default::default(), + ms: 0.0, + skipped: false, + stale: false, + fingerprint: fp, + unkept: false, + state: state.clone(), + }); + continue; + } // The same feature on the same inputs: its earlier result. let key = cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state); @@ -7223,7 +7262,13 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: *warning = Some(warns.join("; ")); } sk.validate_periodic_splines()?; - let profiles = find_profiles(&sk); + // A group of touching curves too large to search whole says so, rather than + // leaving profiles out silently (#6896). + let search = solidcraft_sketch::find_profiles_checked(&sk); + if search.incomplete { + append_warning(warning, profiles_cut_short_note()); + } + let profiles = search.profiles; let ends = solidcraft_sketch::loose_ends(&sk); st.sketches.retain(|s| s.feature != f.id); st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends })); diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs index 146b574168..8139c60a21 100644 --- a/crates/doc/src/eval_cost_tests.rs +++ b/crates/doc/src/eval_cost_tests.rs @@ -647,3 +647,65 @@ fn shared_evaluation_refreshes_live_sketches_and_keeps_unknown_features_conserva assert!(model.results.iter().all(|r| r.error.is_none())); assert!(model.evaluate_shared(&doc), "an unclassified feature must keep the ordinary evaluation path"); } + +/// A saved model writes each set of sketch keys once, as the keys it sets and drops relative to +/// the set written before it (every state repeating its whole set made a 5,484-feature design's +/// model too large for its file, which then kept only some states and evaluated the rest on +/// opening). Every state reads back the keys it was evaluated with, also in a model thinned to +/// fewer states; a set that names no set before it is refused. +#[test] +fn saved_states_read_back_their_sketch_keys() { + let mut doc = Document::new("Sketch keys"); + for i in 0..4 { + doc.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(f64::from(i) + 1.0, 1.0)), None).unwrap(); + for j in 0..3 { + let corner = point_expr(Vec3::new(f64::from(i) * 20.0, f64::from(j) * 20.0, 0.0)); + let kind = FeatureKind::Box { corner, length: "5".into(), width: "5".into(), height: "5".into(), operation: Operation::NewBody }; + doc.add_feature(kind, None).unwrap(); + } + } + let mut model = Model::new(); + model.evaluate(&doc); + let keys = |m: &Model| m.results.iter().map(|r| r.state.sketch_keys.clone()).collect::>(); + assert_eq!(model.state().sketch_keys.len(), 4); + let saved = model.saved(&doc).unwrap(); + assert_eq!(saved.records["sketch_key_sets"].as_array().unwrap().len(), 4, "one set per sketch"); + assert_eq!(saved.records["states"].as_array().unwrap().len(), doc.features.len()); + assert_eq!(keys(&Model::adopt(&doc, &saved).unwrap()), keys(&model)); + // The final state alone (every body costs more than the budget): the sets are all kept. + let pieces: Vec> = (0..saved.bodies.len()).map(|i| vec![(i, 1 << 20)]).collect(); + let (thinned, _, _) = saved.thinned(&pieces, 0).unwrap(); + assert_eq!(thinned.records["states"].as_array().unwrap().len(), 1); + assert_eq!(thinned.final_state().unwrap().sketch_keys, model.state().sketch_keys); + assert_eq!(keys(&Model::adopt(&doc, &thinned).unwrap()), keys(&model)); + // Sets and drops read as written; a base that is not an earlier set is refused. + let with_sets = |sets: serde_json::Value| SavedModel { + records: { + let mut r = saved.records.clone(); + r["sketch_key_sets"] = sets; + r + }, + bodies: saved.bodies.clone(), + }; + let first = *model.state().sketch_keys.keys().next().unwrap(); + let dropped = with_sets(serde_json::json!([ + {"set": [[first, "0000000000000001"], [7, "0000000000000002"]], "drop": []}, + {"base": 0, "set": [], "drop": []}, + {"base": 1, "set": [[8, "0000000000000003"]], "drop": [first]}, + {"base": 0, "set": [[7, "00000000000000ff"]], "drop": []}, + ])); + let want: BTreeMap = [(7, 0xff), (first, 1)].into(); + assert_eq!(dropped.final_state().unwrap().sketch_keys, want); + let dropped = with_sets(serde_json::json!([ + {"set": [[first, "0000000000000001"], [7, "0000000000000002"]], "drop": []}, + {"base": 0, "set": [], "drop": []}, + {"base": 0, "set": [], "drop": []}, + {"base": 1, "set": [[8, "0000000000000003"]], "drop": [first]}, + ])); + assert_eq!(dropped.final_state().unwrap().sketch_keys, [(7, 2), (8, 3)].into()); + for base in [3, 4] { + let sets = + serde_json::json!([{"set": [], "drop": []}, {"set": [], "drop": []}, {"set": [], "drop": []}, {"base": base, "set": [], "drop": []}]); + assert!(with_sets(sets).final_state().is_err(), "base {base}"); + } +} diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index 9c8ca556d4..bcd484d7df 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -161,6 +161,12 @@ pub const FUNCTIONS: &[(&str, &str)] = &[ ("ceil", "ceil(x)"), ("round", "round(x)"), ("exp", "exp(x)"), + ("sinh", "sinh(x)"), + ("cosh", "cosh(x)"), + ("tanh", "tanh(x)"), + ("asinh", "asinh(x)"), + ("acosh", "acosh(x), x ≥ 1"), + ("atanh", "atanh(x), −1 < x < 1"), ("ln", "ln(x)"), ("log", "log(x) (base 10)"), ("log10", "log10(x)"), @@ -344,8 +350,9 @@ fn lex_spans(s: &str) -> Result, DocError> { let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len()); let mut i = 0; let mut out = Vec::new(); - // Parentheses open at this point: a comma outside them can only be a decimal comma. - let mut depth = 0usize; + // Parentheses open at this point, innermost last, true for a function call's: a comma directly + // in a call separates its arguments, and anywhere else can only be a decimal comma (#9408). + let mut parens: Vec = Vec::new(); // A single leading `=` marks the text as a formula (`=d3`, #4681) and is not part of it. if let Some(k) = cs.iter().position(|(_, c)| !c.is_whitespace()) && cs.get(k).is_some_and(|(_, c)| *c == '=') @@ -363,7 +370,7 @@ fn lex_spans(s: &str) -> Result, DocError> { while let Some(&(_, c)) = cs.get(i) { if c.is_whitespace() { i += 1; - } else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, depth, out.last()) { + } else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, in_call(&parens), out.last()) { let st = i; // A leading decimal comma (`,5` for 0,5, #2966) starts the number as `.5` would. let leading_comma = c == ','; @@ -374,11 +381,17 @@ fn lex_spans(s: &str) -> Result, DocError> { i += 1; } // A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347): - // a comma between digits, in a number with no point, outside any parentheses (inside - // them a comma separates a function's arguments: `max(1,2)`). + // a comma between digits, in a number with no point, not directly in a function call + // (there a comma separates arguments: `max(1,2)`). let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit()); let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.')); - if depth == 0 && !leading_comma && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) { + if !in_call(&parens) + && !leading_comma + && !has_point + && digit(i.wrapping_sub(1)) + && cs.get(i).is_some_and(|(_, c)| *c == ',') + && digit(i + 1) + { i += 1; while digit(i) { i += 1; @@ -400,7 +413,7 @@ fn lex_spans(s: &str) -> Result, DocError> { // A mixed number, as imperial sizes are written (`2 1/8"` is 2.125 in, #1802): a whole // number, then a proper fraction of whole numbers, is one value, not `2 × 1/8`. if t.chars().all(|c| c.is_ascii_digit()) - && let Some((fraction, end)) = mixed_fraction(&cs, i, depth) + && let Some((fraction, end)) = mixed_fraction(&cs, i, in_call(&parens)) { out.push((Tok::Num(v + fraction), at(st), at(end))); i = end; @@ -438,8 +451,10 @@ fn lex_spans(s: &str) -> Result, DocError> { i += 1; } else if "+-*/^(),%".contains(c) { match c { - '(' => depth += 1, - ')' => depth = depth.saturating_sub(1), + '(' => parens.push(matches!(out.last(), Some((Tok::Id(_), _, _)))), + ')' => { + parens.pop(); + } _ => {} } out.push((Tok::Op(c), at(i), at(i + 1))); @@ -451,27 +466,32 @@ fn lex_spans(s: &str) -> Result, DocError> { Ok(out) } +/// Whether the innermost open parenthesis is a function call's. +fn in_call(parens: &[bool]) -> bool { + parens.last() == Some(&true) +} + /// Whether the comma at `i` starts a number, as a leading decimal comma (`,5` for 0,5, #2966). -/// It must be outside parentheses (inside them a comma separates arguments, as for any decimal -/// comma), be followed by a digit, and stand where an operand is expected: at the start or right -/// after `+ - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning (`2 ,5` and -/// `1,5,3` stay errors). -fn leading_decimal_comma(cs: &[(usize, char)], i: usize, depth: usize, prev: Option<&(Tok, usize, usize)>) -> bool { - depth == 0 +/// It must not be directly in a function call (`in_call`: there a comma separates arguments, as +/// for any decimal comma), be followed by a digit, and stand where an operand is expected: at the +/// start or right after `( + - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning +/// (`2 ,5` and `1,5,3` stay errors). +fn leading_decimal_comma(cs: &[(usize, char)], i: usize, in_call: bool, prev: Option<&(Tok, usize, usize)>) -> bool { + !in_call && cs.get(i).is_some_and(|(_, c)| *c == ',') && cs.get(i + 1).is_some_and(|(_, c)| c.is_ascii_digit()) && match prev { None => true, - Some((Tok::Op(op), _, _)) => "+-*/^".contains(*op), + Some((Tok::Op(op), _, _)) => "(+-*/^".contains(*op), Some(_) => false, } } /// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`, /// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a -/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the -/// parentheses open there (inside them a comma separates arguments: `max(2 1/8,1)`). -fn mixed_fraction(cs: &[(usize, char)], mut i: usize, depth: usize) -> Option<(f64, usize)> { +/// decimal comma: `2 1/8,5`). Its value and where it ends. `in_call`: directly in a function call +/// there (a comma separates arguments: `max(2 1/8,1)`). +fn mixed_fraction(cs: &[(usize, char)], mut i: usize, in_call: bool) -> Option<(f64, usize)> { let digits = |from: usize| cs.get(from..).map_or(0, |rest| rest.iter().take_while(|(_, c)| c.is_ascii_digit()).count()); let space = i; while cs.get(i).is_some_and(|(_, c)| *c == ' ' || *c == '\t') { @@ -485,7 +505,7 @@ fn mixed_fraction(cs: &[(usize, char)], mut i: usize, depth: usize) -> Option<(f let end = i + numerator + 1 + denominator; let next = |k: usize| cs.get(end + k).map(|(_, c)| *c); let continues = matches!(next(0), Some('.')) - || (depth == 0 && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit())) + || (!in_call && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit())) || (matches!(next(0), Some('e' | 'E')) && next(1).is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+')); if denominator == 0 || continues { return None; @@ -589,9 +609,11 @@ pub fn is_literal(s: &str) -> bool { } } -/// Is the expression a bare number without a unit, like `20` or `-1.5`? +/// Is the expression a bare number without a unit, like `20` or `-1.5`? A leading `=` only marks +/// a formula (#4681), so `=20` is the same bare number. pub fn is_bare_number(s: &str) -> bool { - is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::().is_ok() + let number = s.trim().strip_prefix('=').unwrap_or(s); + is_literal(s) && number.trim().trim_start_matches(['-', '+']).trim().parse::().is_ok() } /// Is `s` shaped like a parameter name: a letter or `_`, then letters, digits and `_`? The shape @@ -943,6 +965,24 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result { unit_fn(r * if scale != 0.0 { scale } else { 1.0 }, a) } "exp" => Value::num(num(one()?)?.exp()), + "sinh" => Value::num(num(one()?)?.sinh()), + "cosh" => Value::num(num(one()?)?.cosh()), + "tanh" => Value::num(num(one()?)?.tanh()), + "asinh" => Value::num(num(one()?)?.asinh()), + "acosh" => { + let x = num(one()?)?; + if !(x >= 1.0) { + return Err(DocError::Expr("`acosh` needs a number of at least 1".into())); + } + Value::num(x.acosh()) + } + "atanh" => { + let x = num(one()?)?; + if !(x > -1.0 && x < 1.0) { + return Err(DocError::Expr("`atanh` needs a number between -1 and 1, both excluded".into())); + } + Value::num(x.atanh()) + } "ln" | "log" | "log10" => { let x = num(one()?)?; if x <= 0.0 { @@ -1214,7 +1254,13 @@ mod tests { // Inside parentheses a comma still separates a function's arguments. assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0); assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0); - assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma"); + // Plain parentheses have no arguments to separate (#9408), also inside a call. + assert_eq!(ev("(2,5)", Kind::Unitless).unwrap(), 2.5); + assert_eq!(ev("-(width-0,5)/2", Kind::Length).unwrap(), -19.75); + assert_eq!(ev("(,5)", Kind::Unitless).unwrap(), 0.5); + assert_eq!(ev("(2 1/8,5)", Kind::Unitless).unwrap(), 2.0 / 8.5); + assert_eq!(ev("max((1,5), 1)", Kind::Unitless).unwrap(), 1.5); + assert_eq!(ev("(max(1,2))", Kind::Unitless).unwrap(), 2.0); // Not between two digits, or a second comma: still an error. for bad in ["2,", "2 ,5", "2, 5", "1,5,3", "1.5,3", ",5,3", ",5.3", "width ,5", "(1) ,5"] { assert!(ev(bad, Kind::Unitless).is_err(), "{bad}"); @@ -1328,6 +1374,15 @@ mod tests { assert!((ev("atan2(1; 1)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12); assert!((ev("exp(ln(5))", Kind::Unitless).unwrap() - 5.0).abs() < 1e-12); assert!((ev("log(1000)", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12); + // Hyperbolic functions (#9332): their definitions in exp, identities and inverses. + assert!((ev("cosh(1.5) - (exp(1.5) + exp(-1.5)) / 2", Kind::Unitless).unwrap()).abs() < 1e-12); + assert!((ev("sinh(1.5) - (exp(1.5) - exp(-1.5)) / 2", Kind::Unitless).unwrap()).abs() < 1e-12); + assert!((ev("tanh(0.7) - sinh(0.7) / cosh(0.7)", Kind::Unitless).unwrap()).abs() < 1e-12); + assert!((ev("cosh(2) ^ 2 - sinh(2) ^ 2", Kind::Unitless).unwrap() - 1.0).abs() < 1e-12); + assert!((ev("asinh(sinh(1.25))", Kind::Unitless).unwrap() - 1.25).abs() < 1e-12); + assert!((ev("acosh(cosh(1.25))", Kind::Unitless).unwrap() - 1.25).abs() < 1e-12); + assert!((ev("atanh(tanh(0.5))", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12); + assert_eq!(ev("cosh(0) + sinh(0) + tanh(0) + asinh(0) + acosh(1) + atanh(0)", Kind::Unitless).unwrap(), 1.0); assert_eq!(ev("floor(2.7) + ceil(0.2) + round(1.5) + abs(-1)", Kind::Unitless).unwrap(), 6.0); assert!((ev("1 rad", Kind::Angle).unwrap() - 1.0).abs() < 1e-12); assert!((ev("acos(0)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12); @@ -1337,6 +1392,22 @@ mod tests { assert_eq!(ev("width^2 / width", Kind::Length).unwrap(), 40.0); } + /// The hyperbolic functions (#9332) take a unit-less number, and the inverses refuse what lies + /// outside their domain, as `asin` and `ln` do. + #[test] + fn hyperbolic_functions_refuse_units_and_numbers_outside_their_domain() { + for (e, says) in [ + ("cosh(2 mm)", "`cosh` needs a unit-less number"), + ("tanh(30 deg)", "`tanh` needs a unit-less number"), + ("acosh(0.5)", "`acosh` needs a number of at least 1"), + ("atanh(1)", "`atanh` needs a number between -1 and 1"), + ("atanh(-1.5)", "`atanh` needs a number between -1 and 1"), + ] { + let err = ev(e, Kind::Unitless).map(|v| v.to_string()).unwrap_or_else(|e| e.to_string()); + assert!(err.contains(says), "{e}: {err}"); + } + } + /// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901). #[test] fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() { @@ -1449,6 +1520,8 @@ mod tests { assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg")); assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width")); assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)")); + // A leading `=` only marks a formula (#4681): `=5` is the bare number `5`, not `==5`. + assert!(is_bare_number("=5") && is_bare_number(" = -1.5") && !is_bare_number("=5 mm") && !is_bare_number("==5") && !is_bare_number("=d3")); assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm")); } diff --git a/crates/doc/src/face_anchor.rs b/crates/doc/src/face_anchor.rs index a8f4ca49db..d32f962c11 100644 --- a/crates/doc/src/face_anchor.rs +++ b/crates/doc/src/face_anchor.rs @@ -172,32 +172,7 @@ impl Document { author: u64, author_path: &[u64], ) -> Result { - // A construction certificate that names this face alone, else its exact history name. - let certified = body - .body - .face_plane(index) - .ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into())) - .and_then(|source| { - let target = self.strong_face_target(body, index, source.origin)?; - if target.certificate.is_none() { - return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into())); - } - 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. - if !indices.contains(&index) { - 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) - }); - let target = match certified { - Ok(target) => target, - Err(error) => match body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)) { - Some(target) => target, - None => return Err(error), - }, - }; + let target = self.flat_face_target(body, index)?; let source = self .anchor_face_plane(body, index, target.certificate.as_ref()) .ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?; @@ -220,6 +195,34 @@ impl Document { Ok(anchor) } + /// A target for flat face `index` of `body` that a rebuild finds again: a construction + /// certificate that names this face alone, else its exact history name. What a sketch on a + /// face and a print face hold. + pub(crate) fn flat_face_target(&self, body: &ModelBody, index: usize) -> Result { + let certified = body + .body + .face_plane(index) + .ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into())) + .and_then(|source| { + let target = self.strong_face_target(body, index, source.origin)?; + if target.certificate.is_none() { + return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into())); + } + 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. + if !indices.contains(&index) { + 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) + }); + match certified { + Ok(target) => Ok(target), + Err(error) => body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)).ok_or(error), + } + } + /// The faces an attachment stands for, and a note when they were found by fallback. /// /// An extrude's cap is certified by the loop of sketch curves around it, so editing that diff --git a/crates/doc/src/format/v45/browser-nested-groups.rs b/crates/doc/src/format/v45/browser-nested-groups.rs new file mode 100644 index 0000000000..13c98678e2 --- /dev/null +++ b/crates/doc/src/format/v45/browser-nested-groups.rs @@ -0,0 +1,6 @@ +//! Browser groups can nest and be ordered. A group saves its `parent` group (omitted at the top +//! level); a group's items and a folder's `browser_order` list may name groups as numbers beside +//! item keys as strings; and `browser_order` has a `"/components"` list ordering a +//! component's sub-components by placement id. Older designs read unchanged: they have no +//! parents and no group entries. The last release refuses the newer format and asks for an +//! update instead of dropping the nesting and the order. diff --git a/crates/doc/src/format/v45/point-cloud-properties.rs b/crates/doc/src/format/v45/point-cloud-properties.rs new file mode 100644 index 0000000000..ea8097212a --- /dev/null +++ b/crates/doc/src/format/v45/point-cloud-properties.rs @@ -0,0 +1,7 @@ +//! A point cloud keeps the other properties its file gives its points (#8816): named numbers per +//! point in `complex` (`columns`, each whole-numbered or not, and the `names` a whole-numbered one's +//! values carry), and it can be coloured by one of them (`color_by: "property"`, with +//! `color_property`, the ramp over `height_range`) and shown only where `filters` keep it (ranges, +//! or sets of named values). Older designs need no transformation: their clouds have no other +//! properties and no filters. The last release refuses a design saved with this (the newer format +//! number) instead of opening its clouds without their properties, colouring and filters. diff --git a/crates/doc/src/format/v45/print-faces.rs b/crates/doc/src/format/v45/print-faces.rs new file mode 100644 index 0000000000..88c7101e6e --- /dev/null +++ b/crates/doc/src/format/v45/print-faces.rs @@ -0,0 +1,5 @@ +//! A design saves the print face of its bodies (`print_faces`, by body name): the flat face a +//! slicer export lays on the print bed, held by the same face target a sketch on a face holds. +//! An older design has none, and every body is exported as it lies, as before. The last +//! release refuses a design that has print faces and asks for an update, instead of opening it +//! and losing them on the next save. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0a750d01bc..fbd94b407b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,6 +10,7 @@ pub mod appearance_pack; mod assembly; mod body_transfer; mod body_transfer_sheet; +mod browser_tree; pub mod pbr_appearance; pub use body_transfer::{valid_transfer_matrix, validate_body_transfer}; pub mod canvas; @@ -53,6 +54,8 @@ pub mod physical_material; mod piece_names; pub mod pipe_thread_catalog; pub mod point_cloud; +mod point_cloud_complex; +pub mod point_cloud_distribution; mod points; mod profile_refs; pub mod progress; @@ -61,6 +64,8 @@ pub use thread_catalog::{TappedThread, TappedThreadFamily, parse_tapped_thread, mod walk; pub use profile_refs::{capture_profile_selection, profile_fallback_notes, profile_selection_indices, profile_selection_indices_lenient}; pub mod plastic; +mod print_face; +pub use print_face::PrintFace; pub mod project; mod project3d; pub mod render_aging; diff --git a/crates/doc/src/model_cache.rs b/crates/doc/src/model_cache.rs index 71b1f4b63b..95ab451494 100644 --- a/crates/doc/src/model_cache.rs +++ b/crates/doc/src/model_cache.rs @@ -22,7 +22,8 @@ pub struct SavedModel { /// Records refer to these at most. const MAX_ITEMS: usize = 1_000_000; -/// Bodies, sketches and sheet bodies all the states hold together, at most (a state is a list of clones). +/// Bodies, sketches, sheet bodies and sketch keys all the states hold together, at most (a state is +/// a list of clones). const MAX_HELD: usize = 50_000_000; /// Longest text a record holds (bytes). A feature's warning can quote every generated name of /// the edges it picked: 122,701 characters in one design, whose saved model was refused, so it @@ -48,6 +49,11 @@ fn unhex(v: &Json) -> Result { v.as_str().and_then(|s| u64::from_str_radix(s, 16).ok()).ok_or_else(|| bad("saved model: key")) } +/// A sketch-key set's list `key` (`set` or `drop`). +fn keys_list<'a>(set: &'a Json, key: &str) -> Result<&'a Vec> { + set.get(key).and_then(Json::as_array).filter(|l| l.len() <= MAX_ITEMS).ok_or_else(|| bad("saved model: sketch keys")) +} + fn bad(what: &str) -> DocError { DocError::Invalid(what.into()) } @@ -128,9 +134,36 @@ struct Writer { sheet_index: HashMap, states: Vec, state_index: HashMap, + /// The states' sketch keys, each distinct map once, written as the changes from the set + /// written before it: every state used to repeat the whole map (one entry per solved sketch), + /// 85 % of a 5,484-feature design's records, which then did not fit the cache's manifest. + sketch_key_sets: Vec, + sketch_key_index: HashMap, usize>, + last_sketch_keys: Option<(BTreeMap, usize)>, } impl Writer { + /// The place of `keys` among the sketch-key sets, written the first time a state holds them + /// (as the changes from the set written last: a later state mostly adds a sketch or two). + fn sketch_key_set(&mut self, keys: &BTreeMap) -> usize { + if let Some(i) = self.sketch_key_index.get(keys) { + return *i; + } + let record = match &self.last_sketch_keys { + Some((base, at)) => { + let set: Vec = keys.iter().filter(|(f, k)| base.get(f) != Some(k)).map(|(f, k)| json!([f, hex(*k)])).collect(); + let drop: Vec = base.keys().filter(|f| !keys.contains_key(f)).copied().collect(); + json!({"base": at, "set": set, "drop": drop}) + } + None => json!({"set": keys.iter().map(|(f, k)| json!([f, hex(*k)])).collect::>(), "drop": []}), + }; + self.sketch_key_sets.push(record); + let i = self.sketch_key_sets.len() - 1; + self.sketch_key_index.insert(keys.clone(), i); + self.last_sketch_keys = Some((keys.clone(), i)); + i + } + fn names(&mut self, b: &ModelBody) -> usize { let id = b.names.id(); if let Some(i) = self.names_index.get(&id) { @@ -144,9 +177,6 @@ impl Writer { } fn model_body(&mut self, b: &ModelBody) -> Option { - if b.body.is_mesh() { - return None; - } let key = BodyKey::of(b); if let Some(i) = self.model_body_index.get(&key) { return Some(*i); @@ -216,7 +246,7 @@ impl Writer { "reserved_body_names": st.reserved_body_names.iter().collect::>(), "sheets": sheets, "construct": serde_json::to_value(&st.construct).ok()?, "threads": serde_json::to_value(&st.threads).ok()?, - "sketch_keys": st.sketch_keys.iter().map(|(f, k)| json!([f, hex(*k)])).collect::>(), + "sketch_key_set": self.sketch_key_set(&st.sketch_keys), "blend_edges": serde_json::to_value(&st.blend_edges).ok()?, "fitted": serde_json::to_value(&st.fitted).ok()?, "tapered": serde_json::to_value(&st.tapered).ok()?, }); @@ -228,7 +258,7 @@ impl Writer { impl Model { /// The model as a design file keeps it: `None` unless it is `doc`'s evaluation without an - /// error, holds no mesh bodies, and `doc` keeps no names saved before format 14. Works out + /// error and `doc` keeps no names saved before format 14. Works out /// the face names not asked for yet (a saved model keeps them as worked out). pub fn saved(&self, doc: &Document) -> Option { if self.results.is_empty() || self.results.iter().any(|r| r.error.is_some()) || !self.is_current(doc) || has_legacy_names(doc) { @@ -250,7 +280,7 @@ impl Model { } let records = json!({ "features": features, "states": w.states, "model_bodies": w.model_bodies, "names": w.names, "sketches": w.sketches, - "sheets": w.sheets, + "sheets": w.sheets, "sketch_key_sets": w.sketch_key_sets, }); Ok(SavedModel { records, bodies: w.bodies }) }) @@ -331,9 +361,49 @@ impl SavedModel { for s in list("sheets")? { sheets.push(serde_json::from_value::(s.clone()).map_err(|_| bad("saved model: sheet"))?); } - let mut states = Vec::new(); + // The sketch-key sets, each the changes from an earlier one (`base`): a short record can + // stand for a large set, so what they hold counts towards MAX_HELD as they are built. + let mut key_sets: Vec> = Vec::new(); let mut held = 0usize; - for s in list("states")? { + for k in list("sketch_key_sets")? { + // A set is the changes from one before it. + let base = match k.get("base") { + Some(b) => Some(key_sets.get(index(Some(b), key_sets.len())?).ok_or_else(|| bad("saved model: sketch keys"))?), + None => None, + }; + let (drop, set) = (keys_list(k, "drop")?, keys_list(k, "set")?); + // Counted before the base is copied: at most what the set can hold. + held = held.saturating_add(base.map_or(0, BTreeMap::len)).saturating_add(set.len()); + if held > MAX_HELD { + return Err(bad("saved model: sketch keys")); + } + let mut keys = base.cloned().unwrap_or_default(); + for f in drop { + keys.remove(&f.as_u64().ok_or_else(|| bad("saved model: sketch keys"))?); + } + for e in set { + keys.insert(e.get(0).and_then(Json::as_u64).ok_or_else(|| bad("saved model: sketch keys"))?, unhex(e.get(1).unwrap_or(&Json::Null))?); + } + key_sets.push(keys); + } + let records = list("states")?; + // What every state holds, counted from its runs and its sketch-key set before any is + // built (a hostile file cannot make the states clone more than MAX_HELD items). + for s in records { + for key in ["bodies", "sketches"] { + for run in s.get(key).and_then(Json::as_array).ok_or_else(|| bad("saved model: state"))? { + held = held.saturating_add(run.get(1).and_then(Json::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX)); + } + } + held = held.saturating_add(s.get("sheets").and_then(Json::as_array).map_or(0, Vec::len)); + held = held.saturating_add(key_sets.get(index(s.get("sketch_key_set"), key_sets.len())?).map_or(0, BTreeMap::len)); + if held > MAX_HELD { + return Err(bad("saved model: states")); + } + } + // Each state is independent of the others once the records they share are read: built + // on threads (a 5,484-feature design spent 3.5 s building them one after another). + let built = kernel::map_on_threads(records, 16, |s: &Json| -> Result { // Runs of consecutive indices, each within the list it indexes. let ids = |key: &str, len: usize| -> Result> { let mut out = Vec::new(); @@ -349,12 +419,8 @@ impl SavedModel { }; let field = |key: &str| parse(s.get(key)); let invalid = |_| bad("saved model: state"); - let mut sketch_keys = BTreeMap::new(); - for k in s.get("sketch_keys").and_then(Json::as_array).ok_or_else(|| bad("saved model: state"))? { - sketch_keys - .insert(k.get(0).and_then(Json::as_u64).ok_or_else(|| bad("saved model: state"))?, unhex(k.get(1).unwrap_or(&Json::Null))?); - } - let st = ModelState { + let sketch_keys = key_sets.get(index(s.get("sketch_key_set"), key_sets.len())?).cloned().ok_or_else(|| bad("saved model: state"))?; + Ok(ModelState { frame_bodies: Default::default(), bodies: ids("bodies", model_bodies.len())?.into_iter().filter_map(|i| model_bodies.get(i).cloned()).collect(), sketches: ids("sketches", sketches.len())?.into_iter().filter_map(|i| sketches.get(i).cloned()).collect(), @@ -377,13 +443,9 @@ impl SavedModel { blend_edges: serde_json::from_value(field("blend_edges")?).map_err(invalid)?, fitted: serde_json::from_value(field("fitted")?).map_err(invalid)?, tapered: serde_json::from_value(field("tapered")?).map_err(invalid)?, - }; - held = held.saturating_add(st.bodies.len()).saturating_add(st.sketches.len()).saturating_add(st.sheets.len()); - if held > MAX_HELD { - return Err(bad("saved model: states")); - } - states.push(Arc::new(st)); - } + }) + }); + let states = built.into_iter().map(|r| r.unwrap_or_else(|| Err(bad("saved model: state"))).map(Arc::new)).collect::>>()?; Ok(states) } @@ -457,7 +519,8 @@ impl SavedModel { let mut stored = std::collections::HashSet::new(); // The sheet bodies, each written once and named by place in the states: all kept. let sheets = list("sheets").cloned().unwrap_or_default(); - let mut used = bytes(sheets.as_slice()).iter().fold(0usize, |t, b| t.saturating_add(*b)); + let key_sets = list("sketch_key_sets").cloned().unwrap_or_default(); + let mut used = bytes(sheets.as_slice()).iter().chain(bytes(key_sets.as_slice()).iter()).fold(0usize, |t, b| t.saturating_add(*b)); for s in std::iter::once(last).chain(order) { let (mut adds, mut new_names, mut new_sketches) = (HashMap::new(), std::collections::HashSet::new(), std::collections::HashSet::new()); let mut records = *state_bytes.get(s)?; @@ -549,7 +612,7 @@ impl SavedModel { } let records = json!({ "features": out_features, "states": out_states, "model_bodies": out_bodies, "names": kept_of(names.as_slice(), &names_used), - "sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets, + "sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets, "sketch_key_sets": key_sets, }); let kept: Vec = (0..self.bodies.len()).filter(|i| kernel_used.get(*i) == Some(&true)).collect(); let bodies = kept.iter().filter_map(|i| self.bodies.get(*i).cloned()).collect(); diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 87f601dcbe..78e6b2c869 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -1150,6 +1150,19 @@ mod design_length_tests { assert_eq!(written("", 5.0), w("mm", "5 mm")); } + /// A bare number typed as a formula (`=5`) means the design's units like `5` does. + #[test] + fn a_formula_that_is_a_bare_number_gets_the_design_unit() { + let mut doc = Document::new("Units"); + doc.units = "in".into(); + let length = crate::expr::Kind::Length; + assert_eq!(doc.with_design_unit("5", length), "5 in"); + assert_eq!(doc.with_design_unit("=5", length), "=5 in"); + assert_eq!(doc.with_design_unit("=5 mm", length), "=5 mm"); + let inches = doc.eval(&doc.with_design_unit("=5", length), length).unwrap(); + assert!((inches - 127.0).abs() < 1e-9, "{inches}"); + } + #[test] fn the_written_value_reads_back_within_a_micrometre() { for units in ["mm", "cm", "m", "in", "ft"] { diff --git a/crates/doc/src/persistent_name.rs b/crates/doc/src/persistent_name.rs index dec333ee22..bbfaadf084 100644 --- a/crates/doc/src/persistent_name.rs +++ b/crates/doc/src/persistent_name.rs @@ -152,6 +152,27 @@ pub enum NameError { }, } +/// Whether `text` can be written into a name whole. +/// +/// A curve's id goes into the name of the faces the curve makes (`F2:side:`), and a +/// name reads some characters as its own: `|` separates the faces of an edge, `,` the faces +/// around a piece, `~` numbers the edges between two faces, `#` numbers a piece and `]` +/// closes one. An id holding one of them splits every name it lands in: the id `c|1` gave +/// the edges of an extrusion the names `F2:side:c|1#1|F2:side:c|1#2~1`, three separators +/// where an edge names two faces and separates them once (#5858). +/// +/// The test is the name's own round trip rather than a list of characters: the text is +/// written into a name and read back, and it must read back as itself. A list would have +/// to follow every change of the grammar (#5861), and would miss an id that reads back as +/// something else (`s1.l4` reads as profile 1 of curve `l4`). +pub fn text_fits_name(text: &str) -> bool { + let name = format!("F0:side:{text}"); + match FaceName::parse(&name) { + Ok(f) => f.to_string() == name && matches!(&f.base, Base::Made { role: Role::Side { profile: None, curve }, .. } if curve == text), + Err(_) => false, + } +} + impl FaceName { /// The feature that made the face: its base's, a copy's when it is one. pub fn feature(&self) -> Option { diff --git a/crates/doc/src/persistent_name/tests.rs b/crates/doc/src/persistent_name/tests.rs index 899f06dbb1..8149d3499c 100644 --- a/crates/doc/src/persistent_name/tests.rs +++ b/crates/doc/src/persistent_name/tests.rs @@ -236,3 +236,15 @@ fn hostile_names_never_panic() { // A fillet on an open boundary's edge. assert!(matches!(face("F1:blend:F10:start").base, Base::Blend { rounds: Rounds::Edge(ref e), .. } if e.faces.len() == 1)); } + +#[test] +fn a_text_that_cannot_be_written_into_a_name_whole_is_told() { + // A curve's id goes into the name of the faces the curve makes, so it has to come + // back out of the name as itself (#5858). + for good in ["c1", "l4", "s01.l4", "c-1", "c_1", "a.b", "x"] { + assert!(text_fits_name(good), "`{good}` fits a name"); + } + for bad in ["c|1", "c,1", "c~1", "c]1", "c#1", "", "s1.l4"] { + assert!(!text_fits_name(bad), "`{bad}` does not fit a name"); + } +} diff --git a/crates/doc/src/placement_reuse.rs b/crates/doc/src/placement_reuse.rs index 473b4bd89d..ca861e19de 100644 --- a/crates/doc/src/placement_reuse.rs +++ b/crates/doc/src/placement_reuse.rs @@ -127,6 +127,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool { text_params, next_component_color_index, materials, + print_faces, user_materials, appearances, body_components, @@ -162,6 +163,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool { && *text_params == b.text_params && *next_component_color_index == b.next_component_color_index && *materials == b.materials + && *print_faces == b.print_faces && *user_materials == b.user_materials && *appearances == b.appearances && *body_components == b.body_components diff --git a/crates/doc/src/point_cloud.rs b/crates/doc/src/point_cloud.rs index 25ad4e1768..3e4cb21b61 100644 --- a/crates/doc/src/point_cloud.rs +++ b/crates/doc/src/point_cloud.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use solidcraft_geom::Vec3; use crate::canvas::{base64_decode, base64_encode}; +pub use crate::point_cloud_complex::{Column, Complex, Filter, Floats, Keep, MAX_COLUMNS, MAX_NAMES, Names}; use crate::{DocError, IDENTITY, Mat, Result}; /// Most points one cloud may hold. @@ -19,6 +20,10 @@ pub const MAX_POINTS: usize = 20_000_000; pub const MAX_COORD: f64 = 1e9; /// Point sizes on screen (pixels). pub const POINT_SIZE: std::ops::RangeInclusive = 0.5..=16.0; +/// Most filters on one cloud. +pub const MAX_FILTERS: usize = 8; +/// Properties every cloud has besides its file's: the coordinates in the design. +pub const COORDINATES: [&str; 3] = ["x", "y", "z"]; /// Length units a point cloud file may be in, and millimetres per unit. pub const UNITS: [(&str, f64); 5] = [("mm", 1.0), ("cm", 10.0), ("m", 1000.0), ("in", 25.4), ("ft", 304.8)]; @@ -65,6 +70,24 @@ pub struct PointCloud { pub height_range: Option<[f64; 2]>, #[serde(default = "yes")] pub visible: bool, + /// The file's other properties of the points. + #[serde(default, skip_serializing_if = "Complex::is_empty")] + pub complex: Complex, + /// The property `color_by: property` colours by. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_property: Option, + /// Only the points every filter keeps are shown. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub filters: Vec, +} + +/// Where a property's values come from. +#[derive(Clone, Copy, Debug)] +pub enum Source<'a> { + /// A coordinate of each point in the design. + Coordinate(usize), + /// A column of the file: each point's. + Column(&'a [f32]), } /// How a cloud's points are coloured. @@ -78,6 +101,8 @@ pub enum ColorBy { Single, /// By height in the design: blue low, through cyan, green and yellow, to red high. Height, + /// By `color_property`: the height ramp over its range or, for whole numbers, a palette. + Property, } impl ColorBy { @@ -90,6 +115,7 @@ impl ColorBy { "file" => Some(ColorBy::File), "single" => Some(ColorBy::Single), "height" => Some(ColorBy::Height), + "property" => Some(ColorBy::Property), _ => None, } } @@ -98,6 +124,7 @@ impl ColorBy { ColorBy::File => "file", ColorBy::Single => "single", ColorBy::Height => "height", + ColorBy::Property => "property", } } } @@ -120,6 +147,27 @@ pub fn height_ramp(t: f64) -> [u8; 3] { std::array::from_fn(|c| (a[c] * (1.0 - f) + b[c] * f).round().clamp(0.0, 255.0) as u8) } +/// Colours of whole-number labels, the label modulo their count; negative labels are grey. +pub const PALETTE: [[u8; 3]; 10] = [ + [240, 140, 40], + [245, 205, 65], + [230, 65, 65], + [165, 75, 190], + [65, 190, 115], + [75, 150, 220], + [240, 120, 180], + [140, 105, 75], + [120, 215, 215], + [190, 190, 80], +]; + +pub fn label_color(v: f64) -> [u8; 3] { + if !(v.is_finite() && v >= 0.0) { + return PLAIN.map(byte); + } + PALETTE[(v.round() as usize) % PALETTE.len()] +} + /// A colour channel (0..1) as a byte. pub fn byte(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 @@ -182,15 +230,57 @@ impl PointCloud { let (lo, hi) = (at(HEIGHT_TAIL)?, at(1.0 - HEIGHT_TAIL)?); Some(if hi - lo > 1e-9 { [lo, hi] } else { [lo - 0.5, lo + 0.5] }) } + /// The property colours come from: a column, or a coordinate (`z` for the height ramp). + pub fn colored_property(&self) -> Option<&str> { + match self.color_by { + ColorBy::Height => Some("z"), + ColorBy::Property => self.color_property.as_deref(), + ColorBy::File | ColorBy::Single => None, + } + } + /// Where the values of `name` are: a coordinate of each point or a column of the file. + pub fn source(&self, name: &str) -> Option> { + if let Some(k) = COORDINATES.iter().position(|n| *n == name) { + return Some(Source::Coordinate(k)); + } + self.complex.column(name).map(|col| Source::Column(&col.values.0)) + } + /// Every property the cloud can be coloured and filtered by: its coordinates, then its columns. + pub fn property_names(&self) -> impl Iterator { + COORDINATES.into_iter().chain(self.complex.columns.iter().map(|c| c.name.as_str())) + } + /// The smallest and largest value of a property, the coordinates in the design. + pub fn span(&self, property: &str) -> Option<[f64; 2]> { + if let Some(span) = self.complex.span(property) { + return Some(span); + } + let k = COORDINATES.iter().position(|n| *n == property)?; + let v = self.world_corners()?.map(|p| [p.x, p.y, p.z][k]); + Some([v.iter().copied().fold(f64::INFINITY, f64::min), v.iter().copied().fold(f64::NEG_INFINITY, f64::max)]) + } + /// The ramp's range as far as it can be told without the elements: the one set, or else the + /// coloured property's span within every range filtering it. The automatic range colouring + /// by a property is exactly what the filters keep + /// ([`crate::point_cloud_distribution::color_range`]), which this bounds. + pub fn color_range(&self) -> Option<[f64; 2]> { + if self.height_range.is_some() { + return self.height_range; + } + let property = self.colored_property()?; + let span = if self.color_by == ColorBy::Height { self.auto_height_range() } else { self.span(property) }?; + let shown = + self.filters.iter().filter(|f| f.property == property).filter_map(Filter::range).fold(span, |[lo, hi], [a, b]| [lo.max(a), hi.min(b)]); + Some(if shown[0] <= shown[1] { crate::point_cloud_distribution::ramp_range(shown) } else { span }) + } /// The heights the height ramp runs between now: the cloud's own, else the automatic ones. pub fn height_range_now(&self) -> Option<[f64; 2]> { self.height_range.or_else(|| self.auto_height_range()) } - /// The colour of each point (by index) as the cloud is shown. - pub fn shade(&self) -> impl Fn(usize) -> [u8; 3] + '_ { + /// The colour of each point (by index) as the cloud is shown, the ramp over `range`. + pub fn shade(&self, range: Option<[f64; 2]>) -> impl Fn(usize) -> [u8; 3] + '_ { let single = self.color.unwrap_or(PLAIN).map(byte); let plain = PLAIN.map(byte); - let range = if self.color_by == ColorBy::Height { self.height_range_now() } else { None }; + let column = self.color_property.as_deref().and_then(|n| self.complex.column(n)); move |i| match self.color_by { ColorBy::File => self.colors.get(i).unwrap_or(plain), ColorBy::Single => single, @@ -198,6 +288,14 @@ impl PointCloud { (Some([lo, hi]), Some(p)) => height_ramp((self.world_z(p) - lo) / (hi - lo)), _ => plain, }, + ColorBy::Property => match (column, column.and_then(|c| c.values.0.get(i))) { + (Some(c), Some(v)) if c.integer => label_color(f64::from(*v)), + (Some(_), Some(v)) => match range { + Some([lo, hi]) if hi > lo => height_ramp((f64::from(*v) - lo) / (hi - lo)), + _ => plain, + }, + _ => plain, + }, } } /// The corners of the box around the points, placed in the design (mm). @@ -240,6 +338,18 @@ impl PointCloud { if !rigid(&self.transform) { return bad("its placement is not a rigid move"); } + self.complex.check(self.points.len())?; + if self.filters.len() > MAX_FILTERS + || self.filters.iter().any(|f| match &f.keep { + Keep::Range { range: [lo, hi] } => !(lo.is_finite() && hi.is_finite() && lo <= hi), + Keep::Values { values } => values.len() > MAX_NAMES, + }) + { + return bad("its filters are not ranges, lowest first"); + } + if self.color_by == ColorBy::Property && self.color_property.is_none() { + return bad("it is coloured by a property without naming it"); + } Ok(()) } } @@ -422,6 +532,9 @@ mod tests { color: None, height_range: None, visible: true, + complex: Complex::default(), + color_property: None, + filters: Vec::new(), } } @@ -443,6 +556,20 @@ mod tests { assert_eq!((unit_scale("M"), unit_scale("yd")), (Some(1000.0), None)); } + #[test] + fn the_ramp_spans_what_is_shown_unless_set() { + let mut c = cloud(vec![0.0; 9], Vec::new()); + c.complex.columns.push(Column { name: "t".into(), integer: false, values: Floats(vec![0.0, 10.0, 20.0].into()) }); + (c.color_by, c.color_property) = (ColorBy::Property, Some("t".into())); + assert_eq!(c.color_range(), Some([0.0, 20.0]), "the whole span"); + c.filters = vec![Filter { property: "t".into(), keep: Keep::Range { range: [5.0, 50.0] } }]; + assert_eq!(c.color_range(), Some([5.0, 20.0]), "the span within the filter"); + c.filters.push(Filter { property: "layer".into(), keep: Keep::Range { range: [0.0, 1.0] } }); + assert_eq!(c.color_range(), Some([5.0, 20.0]), "another property's filter changes nothing"); + c.height_range = Some([1.0, 2.0]); + assert_eq!(c.color_range(), Some([1.0, 2.0]), "a range set is kept"); + } + #[test] fn damaged_clouds_are_refused() { // Not whole points, not finite, not base64. @@ -474,26 +601,26 @@ mod tests { let colors: Vec = (0..102).flat_map(|i| [i as u8, 7, 9]).collect(); let mut c = cloud(values, colors); c.origin = Vec3::ZERO; - assert_eq!(c.shade()(3), [3, 7, 9], "the file's colours"); + assert_eq!(c.shade(c.color_range())(3), [3, 7, 9], "the file's colours"); c.colors = Colors::default(); - assert_eq!(c.shade()(3), PLAIN.map(byte), "none: plain"); + assert_eq!(c.shade(c.color_range())(3), PLAIN.map(byte), "none: plain"); c.color_by = ColorBy::Single; c.color = Some([1.0, 0.5, 0.0]); - assert_eq!(c.shade()(50), [255, 128, 0]); + assert_eq!(c.shade(c.color_range())(50), [255, 128, 0]); // The stray point doesn't stretch the automatic range. c.color_by = ColorBy::Height; // 102 points: the 2nd and the 99th (of 0…101) once sorted. assert_eq!(c.auto_height_range(), Some([2.0, 99.0])); { - let shade = c.shade(); + let shade = c.shade(c.color_range()); assert_eq!((shade(0), shade(100), shade(101)), (height_ramp(0.0), height_ramp(1.0), height_ramp(1.0))); assert_eq!(shade(50), height_ramp(48.0 / 97.0)); } // A range of its own; moved up 100 mm, everything is above it. c.height_range = Some([0.0, 50.0]); - assert_eq!(c.shade()(25), height_ramp(0.5)); + assert_eq!(c.shade(c.color_range())(25), height_ramp(0.5)); c.transform[3][2] = 100.0; - assert_eq!(c.shade()(0), height_ramp(1.0)); + assert_eq!(c.shade(c.color_range())(0), height_ramp(1.0)); assert_eq!((height_ramp(0.0), height_ramp(1.0), height_ramp(f64::NAN)), ([40, 70, 200], [230, 70, 50], [40, 70, 200])); let back: PointCloud = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap(); assert_eq!(back, c); diff --git a/crates/doc/src/point_cloud_complex.rs b/crates/doc/src/point_cloud_complex.rs new file mode 100644 index 0000000000..6635996c72 --- /dev/null +++ b/crates/doc/src/point_cloud_complex.rs @@ -0,0 +1,215 @@ +//! What a point cloud holds besides its points' places and colours: named numbers on its points +//! (a PLY file's other vertex properties), to colour the cloud by and to filter it with. Whole +//! numbers may have names (`role: 0 outer wall, 1 inner wall` in a PLY file's comments): they +//! are labels, filtered by a set of values rather than a range. + +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::canvas::{base64_decode, base64_encode}; +use crate::point_cloud::MAX_POINTS; +use crate::{DocError, Result}; + +/// Most named numbers per point. +pub const MAX_COLUMNS: usize = 64; + +/// One named number on every point. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Column { + pub name: String, + /// Whole numbers (labels, counts): coloured by a palette rather than a ramp. + #[serde(default)] + pub integer: bool, + pub values: Floats, +} + +impl Column { + /// The smallest and largest finite value. + pub fn span(&self) -> Option<[f64; 2]> { + let mut it = self.values.0.iter().filter(|v| v.is_finite()).map(|v| f64::from(*v)); + let first = it.next()?; + Some(it.fold([first, first], |[lo, hi], v| [lo.min(v), hi.max(v)])) + } +} + +/// The complex a cloud's points form: named numbers on the points, and the names of +/// whole-numbered ones. Points alone are a complex of dimension 0. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Complex { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub columns: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub names: Vec, +} + +/// Most named values of one label. +pub const MAX_NAMES: usize = 256; + +/// The names of a label's values. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Names { + pub property: String, + pub values: Vec<(i32, String)>, +} + +impl Names { + /// `property: 0 name, 1 another name, -1 none`, or `None` for any other text. + pub fn parse(text: &str) -> Option { + let (property, rest) = text.split_once(':')?; + let property = property.trim(); + if property.is_empty() || property.contains(char::is_whitespace) { + return None; + } + let values = rest + .split(',') + .map(|entry| { + let (value, name) = entry.trim().split_once(' ')?; + let name = name.trim(); + (!name.is_empty()).then(|| Some((value.parse::().ok()?, name.to_string()))).flatten() + }) + .collect::>>()?; + (!values.is_empty() && values.len() <= MAX_NAMES).then(|| Names { property: property.to_string(), values }) + } + pub fn name(&self, value: i32) -> Option<&str> { + self.values.iter().find(|(v, _)| *v == value).map(|(_, n)| n.as_str()) + } +} + +impl Complex { + pub fn is_empty(&self) -> bool { + self.columns.is_empty() && self.names.is_empty() + } + /// The column named `name`. + pub fn column(&self, name: &str) -> Option<&Column> { + self.columns.iter().find(|c| c.name == name) + } + /// The smallest and largest value named `name`. + pub fn span(&self, name: &str) -> Option<[f64; 2]> { + self.column(name).and_then(Column::span) + } + /// The names of `property`'s values, when it is a label. + pub fn names(&self, property: &str) -> Option<&Names> { + self.names.iter().find(|n| n.property == property).filter(|_| self.integer(property)) + } + /// Whether the values named `name` are whole numbers. + pub fn integer(&self, name: &str) -> bool { + self.column(name).is_some_and(|c| c.integer) + } + /// Every column has one number per point, the cloud having `points` points. + pub fn check(&self, points: usize) -> Result<()> { + let bad = |what: String| Err(DocError::Invalid(format!("point cloud properties: {what}"))); + if self.columns.len() > MAX_COLUMNS { + return bad("too many properties".into()); + } + for c in &self.columns { + if c.values.0.len() != points { + return bad(format!("property `{}` does not have one number per point", c.name)); + } + } + if self.names.len() > MAX_COLUMNS || self.names.iter().any(|n| n.values.is_empty() || n.values.len() > MAX_NAMES) { + return bad("too many named values".into()); + } + Ok(()) + } +} + +/// Which points are shown: those whose property lies in a range (both ends included), or, for a +/// label, is one of a set of values. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Filter { + pub property: String, + #[serde(flatten)] + pub keep: Keep, +} + +/// The values a filter keeps. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Keep { + Range { range: [f64; 2] }, + Values { values: Vec }, +} + +impl Filter { + pub fn range(&self) -> Option<[f64; 2]> { + match &self.keep { + Keep::Range { range } => Some(*range), + Keep::Values { .. } => None, + } + } +} + +/// Numbers, shared between copies of the design. +#[derive(Clone, Debug, Default)] +pub struct Floats(pub Arc<[f32]>); + +impl PartialEq for Floats { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) || self.0.iter().map(|v| v.to_bits()).eq(other.0.iter().map(|v| v.to_bits())) + } +} + +/// The largest base64 text of `n` four-byte words. +fn base64_len(n: usize) -> usize { + (n * 4).div_ceil(3) * 4 + 16 +} + +fn words<'de, D: Deserializer<'de>>(d: D, max: usize) -> std::result::Result, D::Error> { + let s = String::deserialize(d)?; + if s.len() > base64_len(max) { + return Err(serde::de::Error::custom("too many numbers")); + } + let b = base64_decode(&s).ok_or_else(|| serde::de::Error::custom("numbers are not base64"))?; + if !b.len().is_multiple_of(4) { + return Err(serde::de::Error::custom("numbers are not whole four-byte words")); + } + Ok(b.as_chunks::<4>().0.to_vec()) +} + +impl Serialize for Floats { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&base64_encode(&self.0.iter().flat_map(|v| v.to_le_bytes()).collect::>())) + } +} + +impl<'de> Deserialize<'de> for Floats { + fn deserialize>(d: D) -> std::result::Result { + Ok(Floats(words(d, MAX_POINTS)?.into_iter().map(f32::from_le_bytes).collect())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn properties_round_trip_and_are_checked_against_the_points() { + let complex = Complex { + columns: vec![ + Column { name: "t".into(), integer: false, values: Floats(vec![0.0, 0.5, 1.0].into()) }, + Column { name: "role".into(), integer: true, values: Floats(vec![0.0, 4.0, 4.0].into()) }, + ], + names: Vec::new(), + }; + let back: Complex = serde_json::from_str(&serde_json::to_string(&complex).unwrap()).unwrap(); + assert_eq!(back, complex); + complex.check(3).unwrap(); + assert!(complex.check(2).is_err(), "three numbers for two points"); + assert_eq!(complex.span("role"), Some([0.0, 4.0])); + assert!(complex.integer("role") && !complex.integer("t") && !complex.integer("nope")); + // Named values make a label, and a filter keeps a range or a set of them. + let names = Names::parse("role: 0 outer wall, 4 bridge, -1 none").unwrap(); + assert_eq!((names.name(4), names.name(-1), names.name(2)), (Some("bridge"), Some("none"), None)); + for text in ["t: seconds since the start", "role 0 wall", ": 0 a", "role: x wall", "role: 0 "] { + assert!(Names::parse(text).is_none(), "{text}"); + } + let mut named = complex.clone(); + named.names = vec![names, Names::parse("t: 0 zero").unwrap()]; + assert!(named.names("role").is_some() && named.names("t").is_none(), "only whole numbers are labels"); + let set: Filter = serde_json::from_str(r#"{"property": "role", "values": [0, 4]}"#).unwrap(); + let range: Filter = serde_json::from_str(r#"{"property": "t", "range": [1, 3]}"#).unwrap(); + assert_eq!((set.range(), range.range()), (None, Some([1.0, 3.0]))); + assert_eq!(serde_json::to_value(&set).unwrap(), serde_json::json!({"property": "role", "values": [0, 4]})); + } +} diff --git a/crates/doc/src/point_cloud_distribution.rs b/crates/doc/src/point_cloud_distribution.rs new file mode 100644 index 0000000000..4d9e052f83 --- /dev/null +++ b/crates/doc/src/point_cloud_distribution.rs @@ -0,0 +1,246 @@ +//! How much of a point cloud lies at each value of one of its properties, over what its filters +//! keep: the histograms the Edit Point Cloud card draws in its range tracks, split into the +//! colours the view draws, and the values where a label changes along a property, which the +//! tracks snap to. +//! +//! Each point weighs the same, the cloud weighing 1. A range filter keeps the points between its +//! ends, as the view does; a filter by values keeps those whose rounded value it holds; the +//! colour sorts the points kept into the colours they are drawn in. The values the points kept +//! take, from the least to the greatest, are the ramp's automatic range: the colours span exactly +//! what is shown. + +use crate::point_cloud::{ColorBy, Filter, Keep, PALETTE, PointCloud, Source}; + +/// Most bins of one histogram. +pub const MAX_BINS: usize = 8192; + +/// What the histograms need of a cloud's geometry, worked out once for its points. +#[derive(Clone, Debug, Default)] +pub struct Geometry { + /// Each point in the design. + world: Vec<[f64; 3]>, +} + +impl Geometry { + pub fn of(c: &PointCloud) -> Geometry { + Geometry { world: (0..c.len()).map(|i| c.world_point(i).map_or([0.0; 3], |p| [p.x, p.y, p.z])).collect() } + } +} + +/// How a histogram is split by colour, into slots; the last slot holds what has no value to be +/// coloured by. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Colour<'a> { + /// One colour. + One, + /// The ramp over `range` of `property`, in `bands` equal bands; below and above the range + /// count with the first and the last. + Ramp { property: &'a str, range: [f64; 2], bands: usize }, + /// The palette of a whole-numbered `property`: a slot per colour of [`PALETTE`], then one + /// for negative values. + Palette { property: &'a str }, +} + +impl Colour<'_> { + /// How many slots the histogram has. + pub fn slots(&self) -> usize { + match self { + Colour::One => 1, + Colour::Ramp { bands, .. } => bands + 1, + Colour::Palette { .. } => PALETTE.len() + 2, + } + } + fn property(&self) -> Option<&str> { + match self { + Colour::One => None, + Colour::Ramp { property, .. } | Colour::Palette { property } => Some(property), + } + } + /// How a property's values are cut into bands, and the slot of each band. + fn cut(&self) -> Option { + match *self { + Colour::One => None, + Colour::Ramp { range: [lo, hi], bands, .. } => Some(Cut::Grid { start: lo, step: (hi - lo) / bands as f64, count: bands + 1 }), + Colour::Palette { .. } => Some(INTEGERS), + } + } + fn slot(&self, band: usize) -> usize { + match *self { + Colour::One => 0, + Colour::Ramp { bands, .. } => band.saturating_sub(1).min(bands.saturating_sub(1)), + Colour::Palette { .. } => match integer(band) { + Some(v) if v >= 0 => v as usize % PALETTE.len(), + _ => PALETTE.len(), + }, + } + } +} + +/// How much lies in each bin, by colour slot. +#[derive(Clone, Debug, PartialEq)] +pub struct Histogram { + pub bins: usize, + /// `slots[s][b]`: the measure of slot `s` in bin `b`. + pub slots: Vec>, + /// The least and the greatest value the points kept take, within the axis or not. + pub extent: Option<[f64; 2]>, +} + +/// Where values are cut into bands, by increasing values. +#[derive(Clone, Copy, Debug)] +enum Cut { + /// Below (band 0), within (1, both ends included) and above (2) a range. + Range([f64; 2]), + /// `count` values `start + k·step`: band 0 lies below the first, band `k` from the `k`th on. + Grid { start: f64, step: f64, count: usize }, +} + +/// The bands of whole numbers: band `k` holds the values rounding to `k - 2³¹`. +const INTEGERS: Cut = Cut::Grid { start: 0.5 - 2_147_483_648.0, step: 1.0, count: u32::MAX as usize }; + +/// The whole number a band of [`INTEGERS`] holds. +fn integer(band: usize) -> Option { + i64::try_from(band).ok().map(|k| k - 2_147_483_648) +} + +impl Cut { + fn band(&self, v: f64) -> usize { + match *self { + Cut::Range([a, b]) => usize::from(v >= a) + usize::from(v > b), + Cut::Grid { start, step, count } => { + if !(v >= start) { + return 0; + } + (((v - start) / step).floor() as usize).saturating_add(1).min(count) + } + } + } +} + +/// A filter as a cut and the bands it keeps. +struct Kept<'a> { + cut: Cut, + values: Option<&'a [i32]>, +} + +impl Kept<'_> { + fn of(f: &Filter) -> Kept<'_> { + match &f.keep { + Keep::Range { range } => Kept { cut: Cut::Range(*range), values: None }, + Keep::Values { values } => Kept { cut: INTEGERS, values: Some(values) }, + } + } + fn keeps(&self, v: f64) -> bool { + let band = self.cut.band(v); + match self.values { + None => band == 1, + Some(values) => integer(band).is_some_and(|v| values.iter().any(|k| i64::from(*k) == v)), + } + } +} + +/// The value of `s` at point `i`. +fn value(s: Source, i: usize, world: &[[f64; 3]]) -> f64 { + match s { + Source::Coordinate(k) => world.get(i).and_then(|p| p.get(k)).copied().unwrap_or(f64::NAN), + Source::Column(v) => v.get(i).map_or(f64::NAN, |x| f64::from(*x)), + } +} + +/// How much of the cloud lies in each of `bins` bins over `axis` of `property`, by `colour`, +/// within every one of `filters`; `None` for an axis or a number of bins it can't have. +pub fn histogram( + c: &PointCloud, + g: &Geometry, + property: &str, + axis: [f64; 2], + bins: usize, + colour: &Colour, + filters: &[Filter], +) -> Option { + if !(axis[0].is_finite() && axis[1].is_finite() && axis[1] > axis[0]) || bins == 0 || bins > MAX_BINS { + return None; + } + if let Colour::Ramp { range: [lo, hi], bands, .. } = *colour + && !(hi > lo && bands > 0 && bands <= MAX_BINS) + { + return None; + } + let slots = colour.slots(); + let mut out = vec![vec![0.0; bins]; slots]; + let mut extent = None; + let Some(p) = c.source(property) else { return Some(Histogram { bins, slots: out, extent }) }; + // A point is not filtered by a property the cloud doesn't have, as in the view. + let kept: Vec<(Kept, Source)> = filters.iter().filter_map(|f| Some((Kept::of(f), c.source(&f.property)?))).collect(); + let coloured = colour.property().and_then(|n| c.source(n)); + let (colour_cut, plain) = (colour.cut(), slots - 1); + let m = 1.0 / g.world.len().max(1) as f64; + let width = (axis[1] - axis[0]) / bins as f64; + for i in 0..g.world.len() { + if !kept.iter().all(|(k, s)| k.keeps(value(*s, i, &g.world))) { + continue; + } + let v = value(p, i, &g.world); + if !v.is_finite() { + continue; + } + extent = Some(extent.map_or([v, v], |[lo, hi]: [f64; 2]| [lo.min(v), hi.max(v)])); + if !(v >= axis[0] && v <= axis[1]) { + continue; + } + let slot = match (colour_cut, coloured) { + (Some(cut), Some(s)) => colour.slot(cut.band(value(s, i, &g.world))), + (Some(_), None) => plain, + (None, _) => 0, + }; + // The last bin holds the axis' upper end too. + let bin = (((v - axis[0]) / width).floor() as usize).min(bins - 1); + if let Some(b) = out.get_mut(slot).and_then(|s| s.get_mut(bin)) { + *b += m; + } + } + Some(Histogram { bins, slots: out, extent }) +} + +/// A range for a ramp from the values it spans: a single value gets one unit around it. +pub fn ramp_range([lo, hi]: [f64; 2]) -> [f64; 2] { + if hi > lo { [lo, hi] } else { [lo - 0.5, lo + 0.5] } +} + +/// The ramp's range: the one set; else, coloured by a property, exactly the values it takes over +/// what `filters` keep (the cloud's own bound, [`PointCloud::color_range`], when nothing is +/// kept); coloured by height, the cloud's own (its points but the highest and lowest few). +pub fn color_range(c: &PointCloud, g: &Geometry, filters: &[Filter]) -> Option<[f64; 2]> { + color_range_by(c, |property, axis| histogram(c, g, property, axis, 1, &Colour::One, filters).and_then(|h| h.extent)) +} + +/// [`color_range`], with the values the coloured property takes over what is kept given by +/// `extent` (of the property and an axis over its span): worked out elsewhere, or not yet. +pub fn color_range_by(c: &PointCloud, extent: impl FnOnce(&str, [f64; 2]) -> Option<[f64; 2]>) -> Option<[f64; 2]> { + let property = c.colored_property()?; + if c.height_range.is_some() || c.color_by != ColorBy::Property { + return c.color_range(); + } + let axis = ramp_range(c.span(property)?); + extent(property, axis).map(ramp_range).or_else(|| c.color_range()) +} + +/// The values of `property` where the whole-numbered `label` changes, point after point in order +/// of `property`, lowest first. +pub fn label_changes(c: &PointCloud, g: &Geometry, label: &str, property: &str) -> Vec { + let (Some(p), Some(l)) = (c.source(property), c.source(label)) else { return Vec::new() }; + let mut at: Vec<(f64, i64)> = (0..g.world.len()) + .filter_map(|i| { + let (v, l) = (value(p, i, &g.world), value(l, i, &g.world)); + v.is_finite().then(|| (v, if l.is_finite() { l.round() as i64 } else { i64::MIN })) + }) + .collect(); + at.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec = at.windows(2).filter(|w| w[0].1 != w[1].1).map(|w| w[1].0).collect(); + out.dedup(); + out +} + +#[cfg(test)] +#[path = "point_cloud_distribution_tests.rs"] +mod tests; diff --git a/crates/doc/src/point_cloud_distribution_tests.rs b/crates/doc/src/point_cloud_distribution_tests.rs new file mode 100644 index 0000000000..52beaa86cd --- /dev/null +++ b/crates/doc/src/point_cloud_distribution_tests.rs @@ -0,0 +1,125 @@ +use solidcraft_geom::Vec3; + +use super::*; +use crate::IDENTITY; +use crate::point_cloud::{ColorBy, Colors, Column, Complex, Floats, Points}; + +/// A cloud of `points` with `columns` on them. +fn cloud(points: &[[f32; 3]], columns: Vec) -> PointCloud { + PointCloud { + id: 1, + name: "c".into(), + file: String::new(), + origin: Vec3::new(0.0, 0.0, 0.0), + points: Points::new(points.iter().flatten().copied().collect::>().into()), + colors: Colors::default(), + source_points: points.len() as u64, + units: "mm".into(), + transform: IDENTITY, + point_size: 2.0, + color_by: ColorBy::File, + color: None, + height_range: None, + visible: true, + complex: Complex { columns, names: Vec::new() }, + color_property: None, + filters: Vec::new(), + } +} + +fn column(name: &str, integer: bool, values: &[f32]) -> Column { + Column { name: name.into(), integer, values: Floats(values.to_vec().into()) } +} + +fn range(property: &str, range: [f64; 2]) -> Filter { + Filter { property: property.into(), keep: Keep::Range { range } } +} + +fn close(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9) +} + +/// Ten points along x, `t` the same as x. +fn row() -> PointCloud { + let points: Vec<[f32; 3]> = (0..10).map(|i| [i as f32 + 0.5, 0.0, 0.0]).collect(); + let t: Vec = points.iter().map(|p| p[0]).collect(); + cloud(&points, vec![column("t", false, &t)]) +} + +#[test] +fn each_point_weighs_alike_and_a_range_keeps_those_within_it() { + let c = row(); + let g = Geometry::of(&c); + let all = histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[]).unwrap(); + assert!(close(&all.slots[0], &[0.2; 5])); + // Kept from t = 2 to 5: three points, ends included. + let kept = histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[range("t", [2.0, 5.0])]).unwrap(); + assert!(close(&kept.slots[0], &[0.0, 0.2, 0.1, 0.0, 0.0])); + // Filtered by another property: x from 0 to 4 keeps t from 0 to 4. + let by_x = histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[range("x", [0.0, 4.0])]).unwrap(); + assert!(close(&by_x.slots[0], &[0.2, 0.2, 0.0, 0.0, 0.0])); + // A filter on a property the cloud doesn't have keeps everything, as in the view. + assert_eq!(histogram(&c, &g, "t", [0.0, 10.0], 5, &Colour::One, &[range("nope", [0.0, 1.0])]), Some(all)); + // An axis or a number of bins it can't have. + for (axis, bins) in [([1.0, 1.0], 4), ([0.0, f64::NAN], 4), ([0.0, 1.0], 0), ([0.0, 1.0], MAX_BINS + 1)] { + assert!(histogram(&c, &g, "t", axis, bins, &Colour::One, &[]).is_none()); + } +} + +#[test] +fn colours_split_what_is_kept() { + // Four points, roles 0, 3, 3 and -1, t from 0 to 3. + let c = cloud( + &[[0.0; 3], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]], + vec![column("t", false, &[0.0, 1.0, 2.0, 3.0]), column("role", true, &[0.0, 3.0, 3.0, -1.0])], + ); + let g = Geometry::of(&c); + let colour = Colour::Palette { property: "role" }; + let h = histogram(&c, &g, "t", [0.0, 4.0], 4, &colour, &[]).unwrap(); + assert_eq!(h.slots.len(), colour.slots()); + assert!(close(&h.slots[0], &[0.25, 0.0, 0.0, 0.0])); + assert!(close(&h.slots[3], &[0.0, 0.25, 0.25, 0.0])); + assert!(close(&h.slots[PALETTE.len()], &[0.0, 0.0, 0.0, 0.25]), "a negative label"); + // A filter by values keeps role 3 only. + let three = Filter { property: "role".into(), keep: Keep::Values { values: vec![3] } }; + let kept = histogram(&c, &g, "t", [0.0, 4.0], 4, &colour, &[three]).unwrap(); + let total: f64 = kept.slots.iter().flatten().sum(); + assert!((total - 0.5).abs() < 1e-9 && close(&kept.slots[3], &h.slots[3])); + // A ramp over t in two bands, and coloured by a property the cloud doesn't have: plain. + let ramp = Colour::Ramp { property: "t", range: [0.0, 4.0], bands: 2 }; + let r = histogram(&c, &g, "t", [0.0, 4.0], 4, &ramp, &[]).unwrap(); + assert!(close(&r.slots[0], &[0.25, 0.25, 0.0, 0.0]) && close(&r.slots[1], &[0.0, 0.0, 0.25, 0.25])); + let none = histogram(&c, &g, "t", [0.0, 4.0], 4, &Colour::Palette { property: "nope" }, &[]).unwrap(); + assert!(close(&none.slots[PALETTE.len() + 1], &[0.25; 4])); +} + +#[test] +fn labels_change_where_the_next_point_has_another() { + // Six points in order of t, layers 0 0 1 1 2 2. + let points: Vec<[f32; 3]> = (0..6).map(|i| [i as f32, 0.0, 0.0]).collect(); + let c = cloud(&points, vec![column("t", false, &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]), column("layer", true, &[0.0, 0.0, 1.0, 1.0, 2.0, 2.0])]); + let g = Geometry::of(&c); + assert_eq!(label_changes(&c, &g, "layer", "t"), vec![2.0, 4.0]); + assert!(label_changes(&c, &g, "nope", "t").is_empty()); +} + +#[test] +fn the_automatic_range_is_what_every_filter_keeps() { + // Filtered by x from 2 to 4, coloured by t: the range is what is kept of t, though no filter + // is on t (its span within filters on t would be all of it). + let mut c = cloud(&[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0], [20.0, 0.0, 0.0]], vec![column("t", false, &[0.0, 3.0, 5.0, 7.0])]); + c.color_by = ColorBy::Property; + c.color_property = Some("t".into()); + let g = Geometry::of(&c); + let x = [range("x", [2.0, 4.0])]; + assert_eq!(c.color_range(), Some([0.0, 7.0])); + assert_eq!(color_range(&c, &g, &x), Some([3.0, 5.0])); + // The point at x = 20 alone: one value, one unit wide. + assert_eq!(color_range(&c, &g, &[range("x", [19.0, 21.0])]), Some([6.5, 7.5])); + // Nothing kept: the cloud's own bound; a range set is kept as it is. + assert_eq!(color_range(&c, &g, &[range("x", [50.0, 60.0])]), Some([0.0, 7.0])); + c.height_range = Some([1.0, 2.0]); + assert_eq!(color_range(&c, &g, &x), Some([1.0, 2.0])); + let h = histogram(&c, &g, "t", [0.0, 1.0], 4, &Colour::One, &x).unwrap(); + assert_eq!(h.extent, Some([3.0, 5.0]), "the extent reaches past the axis"); +} diff --git a/crates/doc/src/print_face.rs b/crates/doc/src/print_face.rs new file mode 100644 index 0000000000..180443b672 --- /dev/null +++ b/crates/doc/src/print_face.rs @@ -0,0 +1,105 @@ +//! The print face: the flat face of a body that a slicer export lays on the print bed (#8429). +//! A body has at most one (`Document::print_faces`, by body name). It is held by the target a +//! sketch on a face holds ([`Document::flat_face_target`]), so a rebuild that adds, removes or +//! reorders faces finds the same face again, or says it is gone. + +use solidcraft_geom::Vec3; + +use crate::config::ConfigFaceTarget; +use crate::{DocError, Document, ModelBody, ModelState, Result}; + +/// Where a print face is: a point on it and its outward unit normal, in the coordinates of the +/// body it was found on. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrintFace { + /// The face's index on the body (its first piece, when a change split it). + pub index: usize, + pub point: Vec3, + pub normal: Vec3, +} + +impl Document { + /// The target that marks flat face `index` of `body` as its print face. + pub fn print_face_target(&self, body: &ModelBody, index: usize) -> Result { + if body.body.is_mesh() { + return Err(DocError::Invalid("not supported yet: a print face on a mesh body".into())); + } + if body.body.planar_face(index).is_none() { + return Err(DocError::Invalid("only a flat face can be the print face".into())); + } + // The sketch-on-a-face wording ("choose a fixed plane") offers what a print face has not. + self.flat_face_target(body, index).map_err(|_| { + DocError::Invalid( + "this face can't be the print face: the body's history no longer tells it apart from its other faces (as after a shell of a shell body); pick a face of another body, or edit the feature that reshaped it".into(), + ) + }) + } + + /// Where `body`'s print face is now. `None`: it has none. An error: the face can't be found + /// any more, or is no longer flat. + pub fn print_face(&self, state: &ModelState, body: &ModelBody) -> Option> { + let target = self.print_faces.get(body.definition_name())?; + Some(self.locate_print_face(state, body, target)) + } + + /// The print face a copy of `source` gets (Move/Copy, Mirror): the face of `copy` named after + /// `source`'s print face (a copy's faces carry their source's names, followed by the copy's + /// instance, `F1:+x@F2.1`). `None`: + /// `source` has no print face. + pub fn copied_print_face(&self, state: &ModelState, source: &ModelBody, copy: &ModelBody) -> Option> { + let target = self.print_faces.get(source.definition_name())?; + let found = || { + let (indices, _) = self.anchored_face_indices(state, source, target)?; + let first = indices.first().copied().ok_or_else(|| DocError::Unknown("the print face no longer exists".into()))?; + let names = crate::naming::face_names(source); + let name = + names.get(first).map(|n| crate::naming::strip_piece(n)).ok_or_else(|| DocError::Unknown("the print face has no name".into()))?; + let index = crate::naming::face_names(copy) + .iter() + .position(|n| crate::naming::strip_piece(without_last_instance(n)) == name) + .ok_or_else(|| DocError::Unknown(format!("the copy has no face `{name}`")))?; + self.print_face_target(copy, index) + }; + Some(found()) + } + + fn locate_print_face(&self, state: &ModelState, body: &ModelBody, target: &ConfigFaceTarget) -> Result { + if body.body.is_mesh() { + return Err(DocError::Invalid("not supported yet: a print face on a mesh body".into())); + } + let (indices, _) = self.anchored_face_indices(state, body, target)?; + let first = indices.first().copied().ok_or_else(|| DocError::Unknown("the print face no longer exists".into()))?; + let plane = body.body.planar_face(first).ok_or_else(|| DocError::Invalid("the print face is no longer flat".into()))?; + let scale = 1. + body.body.size(); + for index in indices.iter().skip(1) { + let other = body.body.planar_face(*index).ok_or_else(|| DocError::Invalid("the print face is no longer flat".into()))?; + if plane.normal().dot(other.normal()).abs() < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 * scale { + return Err(DocError::Invalid("the print face's pieces no longer lie on one plane".into())); + } + } + // The surface's own orientation is how the kernel built it, so the side the material is + // on is taken from the face's triangles, which point out of the body. + let mesh = body.mesh(); + let mut area = Vec3::ZERO; + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + if let (true, Some([a, b, c])) = (indices.contains(&(*f as usize)), mesh.tri(t)) { + area += (b - a).cross(c - a); + } + } + let normal = plane.normal().normalized().ok_or_else(|| DocError::Invalid("the print face's plane is degenerate".into()))?; + let outward = area.dot(normal); + if !(outward.abs() > 0.) { + return Err(DocError::Invalid("the print face has no surface to lie on".into())); + } + Ok(PrintFace { index: first, point: plane.origin, normal: if outward < 0. { -normal } else { normal } }) + } +} + +/// A face name without the copy instance at its end (`F1:+x@F2.1` is `F1:+x`; a copy of a copy, +/// `F1:+x@F2.1@F5.1`, is `F1:+x@F2.1`). +fn without_last_instance(n: &str) -> &str { + let Some(at) = n.rfind('@') else { return n }; + let instance = n.get(at + 1..).and_then(|t| t.strip_prefix('F')).and_then(|t| t.split_once('.')); + let digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()); + if instance.is_some_and(|(feature, k)| digits(feature) && digits(k)) { n.get(..at).unwrap_or(n) } else { n } +} diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 0889952654..677e68aa60 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -238,13 +238,14 @@ fn resolve_uncached( } LinkSource::Origin => vec![LinkGeom::Point(plane.to_local(Vec3::ZERO))], LinkSource::Axis { name } => { - let d = match name.to_ascii_uppercase().as_str() { - "X" => Vec3::X, - "Y" => Vec3::Y, - "Z" => Vec3::Z, - _ => return Err(DocError::Unknown(format!("axis `{name}`"))), + let (o, d) = match name.to_ascii_uppercase().as_str() { + "X" => (Vec3::ZERO, Vec3::X), + "Y" => (Vec3::ZERO, Vec3::Y), + "Z" => (Vec3::ZERO, Vec3::Z), + // A construction axis, by name (#9324), where the timeline so far puts it. + _ => crate::eval::construction_axis_in(Some(doc), st, name, 0)?, }; - let o = plane.to_local(Vec3::ZERO); + let o = plane.to_local(o); let d2 = Vec2::new(d.dot(plane.x), d.dot(plane.y)); match d2.normalized() { Some(u) if d2.len() > 1e-9 => vec![LinkGeom::Line(o - u * AXIS_HALF, o + u * AXIS_HALF)], diff --git a/crates/doc/src/render_settings.rs b/crates/doc/src/render_settings.rs index 4bec6f7c23..1d575648be 100644 --- a/crates/doc/src/render_settings.rs +++ b/crates/doc/src/render_settings.rs @@ -39,6 +39,12 @@ pub struct RenderSetup { pub stage: solidcraft_render::stage::Stage, } +/// The CPU request a size check belongs to, so a refusal can name the profile that admits it. +#[derive(Clone, Copy)] +struct CpuSize { + progressive: Option, +} + // Reuse Camera's representation and defaults, but reject misspelled saved fields. A remote // derive constructs Camera itself, so adding a Camera field requires updating this adapter. // Navigation/named-view deserialization keeps its existing compatibility policy. @@ -80,27 +86,29 @@ impl Default for RenderSetup { impl RenderSetup { pub fn validate(&self) -> Result<()> { - let limits = match &self.job { - Some(RenderJob::CpuRaytrace { options }) => options.budget.image_limits(options.progressive.is_some()), - Some(RenderJob::GpuPath { .. }) => solidcraft_render::job::GPU_PATH_LIMITS, - _ => RenderLimits::default(), + let (limits, cpu) = match &self.job { + Some(RenderJob::CpuRaytrace { options }) => { + (options.budget.image_limits(options.progressive.is_some()), Some(CpuSize { progressive: options.progressive })) + } + Some(RenderJob::GpuPath { .. }) => (solidcraft_render::job::GPU_PATH_LIMITS, None), + _ => (RenderLimits::default(), None), }; - self.validate_with_limits(limits) + self.validate_with_limits(limits, cpu) } /// Runtime CPU lowering has already moved typed options out of this setup. /// Saving still uses validate(), so a legacy setup cannot smuggle a large profile. pub fn validate_for_cpu(&self, budget: solidcraft_render::job::CpuBudget) -> Result<()> { budget.validate_image(self.width, self.height, None).map_err(|e| DocError::Invalid(e.to_string()))?; - self.validate_with_limits(budget.image_limits(false)) + self.validate_with_limits(budget.image_limits(false), Some(CpuSize { progressive: None })) } /// Typed GPU options have already been lowered; retain their dimension envelope. pub fn validate_for_gpu(&self) -> Result<()> { - self.validate_with_limits(solidcraft_render::job::GPU_PATH_LIMITS) + self.validate_with_limits(solidcraft_render::job::GPU_PATH_LIMITS, None) } - fn validate_with_limits(&self, limits: RenderLimits) -> Result<()> { + fn validate_with_limits(&self, limits: RenderLimits, cpu: Option) -> Result<()> { self.surfaces.validate()?; self.optics.validate()?; if !self.optics.is_empty() && !matches!(self.job, Some(RenderJob::GpuPath { .. })) { @@ -123,9 +131,14 @@ impl RenderSetup { if let Some((w, h)) = limits.fit(width, height, None) && (w, h) != (width, height) { + // A CPU refusal names the profile that can render the size (#8941). It is never + // selected for the person: resolution and profile change only when they choose. + let advice = cpu.map_or_else(String::new, |cpu| { + format!("; {}", solidcraft_render::job::CpuBudget::size_advice(self.width, self.height, cpu.progressive)) + }); return Err(DocError::Invalid(format!( "render output {width} × {height} exceeds the image limits ({} pixels per side, {} pixels in all); \ - the largest image at this aspect is {w} × {h}", + the largest image at this aspect is {w} × {h}{advice}", limits.max_dimension, limits.max_pixels ))); } @@ -538,9 +551,58 @@ mod tests { let setup = RenderSetup { width: 7680, height: 4320, ..Default::default() }; let message = setup.validate().unwrap_err().to_string(); assert!(message.contains("the largest image at this aspect is 27"), "{message}"); + assert!(!message.contains("CPU resource profile"), "a raster refusal names no CPU profile: {message}"); assert!(RenderSetup { width: 2048, height: 2048, ..Default::default() }.validate().is_ok()); } + /// #8941: 4K UHD under the default Compact profile names Studio, the profile that renders it; + /// a 16K-wide image under Studio names Workstation; past Workstation no profile is named. + /// The saved profile is never changed by the check, and GPU path tracing keeps its own limits. + #[test] + fn a_cpu_size_refusal_names_the_profile_that_can_render_it() { + use solidcraft_render::job::CpuBudget; + let cpu = |budget, width, height| RenderSetup { + width, + height, + job: Some(RenderJob::CpuRaytrace { options: CpuJob { budget, ..Default::default() } }), + ..Default::default() + }; + let compact = cpu(CpuBudget::Compact, 3840, 2160); + let message = compact.validate().unwrap_err().to_string(); + assert!(message.contains("3840 × 2160 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}"); + assert!(message.contains("the largest image at this aspect is 2730 × 1536"), "{message}"); + assert!(message.contains("choose the Studio CPU resource profile, or a smaller size"), "{message}"); + assert!(matches!(&compact.job, Some(RenderJob::CpuRaytrace { options }) if options.budget == CpuBudget::Compact)); + let message = compact.validate_for_cpu(CpuBudget::Compact).unwrap_err().to_string(); + assert!(message.contains("choose the Studio CPU resource profile"), "{message}"); + + cpu(CpuBudget::Studio, 3840, 2160).validate().unwrap(); + RenderSetup { width: 3840, height: 2160, ..Default::default() }.validate_for_cpu(CpuBudget::Studio).unwrap(); + + let message = cpu(CpuBudget::Studio, 15360, 4320).validate().unwrap_err().to_string(); + assert!(message.contains("choose the Workstation CPU resource profile"), "{message}"); + cpu(CpuBudget::Workstation, 15360, 4320).validate().unwrap(); + + let message = cpu(CpuBudget::Workstation, 16384, 16384).validate().unwrap_err().to_string(); + assert!(message.contains("no CPU resource profile accepts this size; choose a smaller size"), "{message}"); + for profile in ["Compact", "Studio", "Workstation"] { + assert!(!message.contains(&format!("the {profile} CPU")), "{message}"); + } + + // GPU path tracing: 4K renders, an over-limit size names no CPU profile. + let gpu = |width, height| RenderSetup { + width, + height, + job: Some(RenderJob::GpuPath { transparent_background: false, options: Default::default() }), + ..Default::default() + }; + gpu(3840, 2160).validate().unwrap(); + gpu(3840, 2160).validate_for_gpu().unwrap(); + let message = gpu(8193, 8193).validate().unwrap_err().to_string(); + assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}"); + assert!(!message.contains("CPU resource profile"), "{message}"); + } + #[test] fn portable_jobs_preserve_complete_numerical_intent_and_legacy_omission() { use serde_json::json; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index cc9b05989a..e3051078a4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -486,6 +486,8 @@ fn hostile_expressions_never_panic() { "deg", "x", "sqrt", + "cosh", + "atanh", "pow", "min", "max", @@ -715,3 +717,156 @@ fn timeline_group_legacy_json_omits_empty_joint_membership() { assert!(value["timeline_groups"][0].get("joints").is_none()); assert_eq!(Document::from_json(&saved).unwrap().to_json(), saved); } + +/// A sketch whose touching curves are cut into more pieces than the profile search looks at +/// says that some profiles may be missing, instead of leaving them out silently (#6896); an +/// ordinary sketch says nothing. +#[test] +fn a_sketch_too_large_to_search_whole_says_profiles_may_be_missing() { + let mut doc = Document::new("large"); + let mut sk = Sketch::new(); + // Concentric ellipses 100 m across, each sampled into thousands of pieces, all one island. + let c = sk.add_point(Vec2::ZERO, None).unwrap(); + for k in 0..5 { + let m = sk.add_point(Vec2::new(1e5 - k as f64 * 1e3, 0.0), None).unwrap(); + sk.add_curve(solidcraft_sketch::CurveKind::Ellipse { c, m, r: 6e4 - k as f64 * 1e3 }, None).unwrap(); + } + let large = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap(); + let (plate, plate_sketch) = plate_doc(); + let mut m = Model::new(); + m.evaluate(&doc); + let warning = m.result(large).and_then(|r| r.warning.clone()).unwrap_or_default(); + assert!(warning.contains("some profiles may be missing"), "{warning}"); + let mut m = Model::new(); + m.evaluate(&plate); + assert!(m.result(plate_sketch).and_then(|r| r.warning.as_deref()).is_none()); +} + +fn browser_group(id: u64, component: u64, folder: &str, parent: Option, items: &[BrowserEntry]) -> BrowserGroup { + BrowserGroup { id, name: format!("G{id}"), component, folder: folder.into(), parent, items: items.to_vec() } +} + +fn item(key: &str) -> BrowserEntry { + BrowserEntry::item(key) +} + +fn group(id: u64) -> BrowserEntry { + BrowserEntry::Group(id) +} + +/// The design with what `browser_pruned` drops gone (the design itself when nothing is). +fn pruned(doc: &Document) -> Document { + let mut out = doc.clone(); + if let Some((groups, order)) = doc.browser_pruned() { + (out.browser_groups, out.browser_order) = (groups, order); + } + out +} + +/// Pruning keeps every group but cuts a parent that loops, is the group itself, is past the +/// nesting limit, or is in another component or folder, to the top level; and it drops entries +/// for deleted sketches and placements, repeats, and the lists of a missing component. Body names +/// are never judged. +#[test] +fn browser_pruning_cuts_bad_parents_and_drops_stale_and_repeated_entries() { + let mut doc = Document::new("prune"); + let sketch = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, None).unwrap(); + let key = sketch.to_string(); + doc.components.push(Component { id: 7, name: "Part".into(), ..Default::default() }); + doc.occurrences.push(Occurrence { id: 20, component: 7, parent: 0, name: "Part".into(), transform: IDENTITY, grounded: false }); + let deep = MAX_GROUP_NESTING as u64 + 2; + doc.browser_groups = vec![ + browser_group(1, 0, "bodies", Some(2), &[]), + browser_group(2, 0, "bodies", Some(1), &[]), + browser_group(3, 0, "bodies", Some(3), &[]), + browser_group(4, 7, "bodies", Some(5), &[]), + browser_group(5, 0, "bodies", None, &[item("a"), group(7), group(7), item("a")]), + browser_group(6, 0, "sketches", Some(5), &[item(&key), item("999")]), + browser_group(7, 0, "bodies", Some(5), &[]), + browser_group(8, 0, "bodies", None, &[item("a"), item("b")]), + ]; + doc.browser_groups.extend((0..deep).map(|i| browser_group(100 + i, 0, "construction", i.checked_sub(1).map(|p| 100 + p), &[]))); + doc.browser_order.insert("0/bodies".into(), vec![item("Lid"), item("Ghost"), item("Lid"), group(5), group(5)]); + doc.browser_order.insert("0/components".into(), vec![item("21"), item("20")]); + doc.browser_order.insert("99/bodies".into(), vec![item("x")]); + let after = pruned(&doc); + let parent = |id| after.browser_group(id).unwrap().parent; + assert_eq!(after.browser_groups.len(), doc.browser_groups.len(), "no group is dropped"); + assert_eq!([1, 2, 3, 4, 6].map(parent), [None; 5], "a loop, itself, another component, another folder"); + assert_eq!(parent(7), Some(5)); + for i in 0..deep { + assert_eq!(parent(100 + i), (1..MAX_GROUP_NESTING as u64).contains(&i).then(|| 99 + i), "level {} of a chain {deep} deep", i + 1); + } + assert_eq!(after.browser_group(5).unwrap().items, [item("a"), group(7)]); + assert_eq!(after.browser_group(8).unwrap().items, [item("b")], "an item stays in the first group that has it"); + assert_eq!(after.browser_group(6).unwrap().items, [item(&key)], "the deleted sketch goes"); + assert_eq!(after.browser_order["0/bodies"], [item("Lid"), item("Ghost"), group(5)], "body names stay; repeats go"); + assert_eq!(after.browser_order["0/components"], [item("20")], "the deleted placement goes"); + assert!(!after.browser_order.contains_key("99/bodies")); + assert!(Document::new("clean").browser_pruned().is_none(), "nothing to prune is not a change"); +} + +/// Levels and ancestry follow the parents. Groups a list does not name go after the last one it +/// names, in creation order, or first when it names none: above the items, as a design saved +/// before groups could be ordered has always shown them. +#[test] +fn group_levels_and_the_order_of_groups_a_list_does_not_name() { + let mut doc = Document::new("order"); + doc.browser_groups = vec![ + browser_group(1, 0, "bodies", None, &[item("x")]), + browser_group(2, 0, "bodies", None, &[]), + browser_group(3, 0, "bodies", None, &[]), + browser_group(4, 0, "bodies", Some(1), &[]), + browser_group(5, 0, "bodies", Some(4), &[]), + ]; + assert_eq!([1, 2, 4, 5].map(|id| doc.group_level(id)), [1, 1, 2, 3]); + assert!(doc.group_within(5, 1) && doc.group_within(5, 5) && !doc.group_within(1, 5) && !doc.group_within(2, 5)); + assert_eq!(doc.group_height(1), 3); + let top = |doc: &Document| doc.ordered_entries(0, "bodies", None); + assert_eq!(top(&doc), [group(1), group(2), group(3)], "no list: creation order"); + doc.browser_order.insert("0/bodies".into(), vec![item("a")]); + assert_eq!(top(&doc), [group(1), group(2), group(3), item("a")], "a list of items only: groups above them"); + doc.browser_order.insert("0/bodies".into(), vec![item("a"), group(2), item("b"), group(2), group(9)]); + assert_eq!(top(&doc), [item("a"), group(2), group(1), group(3), item("b")], "after the last listed; repeats and unknown ignored"); + assert_eq!(doc.ordered_entries(0, "bodies", Some(1)), [group(4), item("x")], "inside a group too"); +} + +/// Nested groups and a `components` order survive a save and a load, saved as numbers (groups) and +/// strings (items). A design from before groups could nest reads with the browser it always had: +/// no parents, groups in creation order above the items in their saved order, saved back as read. +#[test] +fn browser_data_survives_a_save_and_old_designs_read_unchanged() { + let mut doc = Document::new("nested"); + doc.components.push(Component { id: 7, name: "Part".into(), ..Default::default() }); + for id in [20, 21] { + doc.occurrences.push(Occurrence { id, component: 7, parent: 0, name: format!("P{id}"), transform: IDENTITY, grounded: false }); + } + doc.browser_groups = vec![browser_group(2, 0, "bodies", None, &[item("Lid"), group(3)]), browser_group(3, 0, "bodies", Some(2), &[item("Base")])]; + doc.browser_order.insert("0/bodies".into(), vec![item("Cap"), group(2)]); + doc.browser_order.insert("0/components".into(), vec![item("21"), item("20")]); + let saved = doc.to_json(); + let value: serde_json::Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(value["browser_order"]["0/bodies"], serde_json::json!(["Cap", 2])); + assert_eq!((&value["browser_groups"][1]["parent"], value["browser_groups"][0].get("parent")), (&serde_json::json!(2), None)); + let loaded = Document::from_json(&saved).unwrap(); + assert_eq!((&loaded.browser_groups, &loaded.browser_order), (&doc.browser_groups, &doc.browser_order)); + let resaved: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap(); + assert_eq!((&resaved["browser_groups"], &resaved["browser_order"]), (&value["browser_groups"], &value["browser_order"])); + + let groups = serde_json::json!([ + {"id": 3, "name": "Fasteners", "component": 0, "folder": "bodies", "items": ["Bolt", "Nut"]}, + {"id": 4, "name": "Plates", "component": 0, "folder": "bodies", "items": ["Plate"]}, + ]); + for (order, top) in [ + (serde_json::json!({}), vec![group(3), group(4)]), + (serde_json::json!({"0/bodies": ["Washer", "Bolt", "Cap"]}), vec![group(3), group(4), item("Washer"), item("Bolt"), item("Cap")]), + ] { + let mut old: serde_json::Value = serde_json::from_str(&Document::new("old").to_json()).unwrap(); + (old["browser_groups"], old["browser_order"]) = (groups.clone(), order.clone()); + let loaded = Document::from_json(&old.to_string()).unwrap(); + assert!(loaded.browser_groups.iter().all(|g| g.parent.is_none()), "{order}"); + assert_eq!(loaded.ordered_entries(0, "bodies", None), top, "{order}"); + let resaved: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap(); + assert_eq!(resaved["browser_groups"], groups, "{order}"); + } +} diff --git a/crates/doc/src/text_params.rs b/crates/doc/src/text_params.rs index 7fb35bb1d9..53866b029c 100644 --- a/crates/doc/src/text_params.rs +++ b/crates/doc/src/text_params.rs @@ -3,9 +3,17 @@ //! kept apart from the number parameters: a text has no unit and no number expression reads it. //! A parameter edit or a configuration row that sets one relabels every text built from it, and //! the features that use those texts follow in the same rebuild. +//! +//! The words can show a number parameter's value too (#9281): `'Width: ' + Width` reads +//! `Width: 85 mm`, a length in the design's units, an angle in degrees (`30°`), up to three +//! decimals without trailing zeros. `number(Width)` is the number alone (`85`) and +//! `number(Width, 2)` gives it two decimals (`85.00`). use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::expr::Value; use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result}; /// The unit that marks a text parameter where parameters are listed, added or imported. @@ -49,8 +57,29 @@ pub fn unquote(s: &str) -> String { enum Term { Text(String), Name(String), + /// `number(name)` or `number(name, decimals)`, with the name's byte range. + Number { + name: String, + at: (usize, usize), + decimals: Option, + }, } +/// How the words show a number parameter. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Show { + /// As named: its value and unit, up to three decimals. + Named, + /// In `number(...)`: the number alone, with these decimals or up to three. + Number(Option), +} + +/// The function that shows a number parameter's value without its unit. +const NUMBER: &str = "number"; + +/// Most decimals `number(name, decimals)` takes. +const MAX_DECIMALS: u8 = 8; + /// The terms of a words expression with their byte ranges: quoted texts (single or double /// quotes, a quote doubled inside stands for one) and parameter names, joined by `+`. fn terms(s: &str) -> Result> { @@ -96,10 +125,16 @@ fn terms(s: &str) -> Result> { want_term = false; } else if c.is_alphabetic() || c == '_' { let start = i; - while cs.get(i).is_some_and(|x| x.1.is_alphanumeric() || x.1 == '_') { - i += 1; + i = word_end(&cs, i); + let word = s.get(at(start)..at(i)).unwrap_or_default().to_string(); + let open = skip_blanks(&cs, i); + if word == NUMBER && cs.get(open).is_some_and(|x| x.1 == '(') { + let (term, end) = number_call(s, &cs, open + 1)?; + i = end; + out.push((term, at(start), at(i))); + } else { + out.push((Term::Name(word), at(start), at(i))); } - out.push((Term::Name(s.get(at(start)..at(i)).unwrap_or_default().to_string()), at(start), at(i))); want_term = false; } else { return Err(DocError::Expr(format!("unexpected `{c}`: a text expression joins quoted texts and text parameters with +"))); @@ -112,11 +147,88 @@ fn terms(s: &str) -> Result> { Ok(out) } +fn word_end(cs: &[(usize, char)], mut i: usize) -> usize { + while cs.get(i).is_some_and(|x| x.1.is_alphanumeric() || x.1 == '_') { + i += 1; + } + i +} + +fn skip_blanks(cs: &[(usize, char)], mut i: usize) -> usize { + while cs.get(i).is_some_and(|x| x.1.is_whitespace()) { + i += 1; + } + i +} + +/// `number(` has been read up to `i`: the name, optional decimals and `)`; the term and where +/// it ends. +fn number_call(s: &str, cs: &[(usize, char)], i: usize) -> Result<(Term, usize)> { + let at = |i: usize| cs.get(i).map_or(s.len(), |x| x.0); + let usage = || DocError::Expr("number() takes a number parameter and, if wanted, its decimals: number(Width, 2)".into()); + let start = skip_blanks(cs, i); + if !cs.get(start).is_some_and(|x| x.1.is_alphabetic() || x.1 == '_') { + return Err(usage()); + } + let end = word_end(cs, start); + let name = s.get(at(start)..at(end)).unwrap_or_default().to_string(); + let mut i = skip_blanks(cs, end); + let mut decimals = None; + if cs.get(i).is_some_and(|x| x.1 == ',') { + let from = skip_blanks(cs, i + 1); + let mut to = from; + while cs.get(to).is_some_and(|x| x.1.is_ascii_digit()) { + to += 1; + } + let digits = s.get(at(from)..at(to)).unwrap_or_default(); + let n: u8 = digits.parse().map_err(|_| usage())?; + if n > MAX_DECIMALS { + return Err(DocError::Expr(format!("number() shows at most {MAX_DECIMALS} decimals"))); + } + decimals = Some(n); + i = skip_blanks(cs, to); + } + if !cs.get(i).is_some_and(|x| x.1 == ')') { + return Err(usage()); + } + Ok((Term::Number { name, at: (at(start), at(end)), decimals }, i + 1)) +} + +/// A number parameter's value as the words show it: `v` in millimetres, radians or no unit +/// (`len`, `ang`: its dimension), lengths in `units`. +pub fn show_number(v: f64, len: i8, ang: i8, units: &str, show: Show) -> Result { + let (value, unit) = match (len, ang) { + (0, 0) => (v, ""), + (1, 0) => { + let (scale, name) = crate::display_units::unit_scale(units); + (v * scale, name) + } + (0, 1) => (v.to_degrees(), "°"), + _ => return Err(DocError::Expr("a text shows lengths, angles and plain numbers only".into())), + }; + if !value.is_finite() || value.abs() > 1e12 { + return Err(DocError::Expr("the number is out of range for a text".into())); + } + let digits = match show { + Show::Number(Some(d)) => format!("{value:.*}", usize::from(d)), + Show::Named | Show::Number(None) => { + let t = format!("{value:.3}"); + let t = t.trim_end_matches('0').trim_end_matches('.'); + if t == "-0" { "0".to_string() } else { t.to_string() } + } + }; + Ok(match (show, unit) { + (Show::Number(_), _) | (_, "") => digits, + (Show::Named, "°") => format!("{digits}°"), + (Show::Named, unit) => format!("{digits} {unit}"), + }) +} + /// The parameters a words expression names (none when it does not parse). pub fn references(s: &str) -> Vec { let mut out: Vec = Vec::new(); for (t, _, _) in terms(s).unwrap_or_default() { - if let Term::Name(n) = t + if let Term::Name(n) | Term::Number { name: n, .. } = t && !out.contains(&n) { out.push(n); @@ -131,23 +243,27 @@ pub fn rename_reference(s: &str, old: &str, new: &str) -> String { let mut out = String::with_capacity(s.len()); let mut last = 0; for (t, a, b) in terms { - if matches!(&t, Term::Name(n) if n == old) { - out.push_str(s.get(last..a).unwrap_or_default()); - out.push_str(new); - last = b; - } + let at = match &t { + Term::Name(n) if n == old => (a, b), + Term::Number { name, at, .. } if name == old => *at, + _ => continue, + }; + out.push_str(s.get(last..at.0).unwrap_or_default()); + out.push_str(new); + last = at.1; } out.push_str(s.get(last..).unwrap_or_default()); out } /// The words an expression comes to, each parameter's text from `lookup`. -pub fn eval(s: &str, lookup: &dyn Fn(&str) -> Result) -> Result { +pub fn eval(s: &str, lookup: &dyn Fn(&str, Show) -> Result) -> Result { let mut out = String::new(); for (t, _, _) in terms(s)? { match t { Term::Text(text) => out.push_str(&text), - Term::Name(n) => out.push_str(&lookup(&n)?), + Term::Name(n) => out.push_str(&lookup(&n, Show::Named)?), + Term::Number { name, decimals, .. } => out.push_str(&lookup(&name, Show::Number(decimals))?), } if out.len() > MAX_LEN { return Err(DocError::Expr("the text is too long".into())); @@ -161,12 +277,14 @@ impl Document { self.text_params.iter().find(|p| p.name == name) } - /// The words a words expression comes to in this design. - pub fn eval_words(&self, e: &str) -> Result { - eval(e, &|n| match self.text_param(n) { - Some(p) => Ok(p.text.clone()), - None if self.name_taken(n) => Err(DocError::Expr(format!("`{n}` is a number parameter: a text's words take text parameters"))), - None => Err(DocError::Expr(format!("unknown text parameter `{n}`"))), + /// The words a words expression comes to in this design, number parameters from `vals`. + pub fn eval_words(&self, e: &str, vals: &BTreeMap) -> Result { + eval(e, &|n, show| match (self.text_param(n), vals.get(n)) { + (Some(_), _) if show != Show::Named => Err(DocError::Expr(format!("`{n}` is a text parameter: number() takes a number parameter"))), + (Some(p), _) => Ok(p.text.clone()), + (None, Some(v)) => show_number(v.v, v.len, v.ang, &self.units, show).map_err(|error| DocError::Expr(format!("`{n}`: {error}"))), + (None, None) if self.name_taken(n) => Err(DocError::Expr(format!("`{n}` has no value"))), + (None, None) => Err(DocError::Expr(format!("unknown parameter `{n}`"))), }) } @@ -230,7 +348,7 @@ mod tests { #[test] fn words_expressions_join_quoted_texts_and_parameters() { - let look = |n: &str| match n { + let look = |n: &str, _: Show| match n { "Label" => Ok("TAG-1".to_string()), _ => Err(DocError::Expr(format!("unknown text parameter `{n}`"))), }; @@ -249,4 +367,30 @@ mod tests { assert_eq!(unquote("'a' + 'b'"), "'a' + 'b'"); assert_eq!(unquote("TAG-1"), "TAG-1"); } + + /// #9281: a number parameter in the words shows its value; `number()` drops the unit and + /// can fix the decimals; a rename reaches inside `number()`. + #[test] + fn number_parameters_show_their_value_in_the_words() { + let look = |n: &str, show: Show| match n { + "Width" => show_number(85.0, 1, 0, "mm", show), + "Gap" => show_number(12.7, 1, 0, "in", show), + "Tilt" => show_number(30f64.to_radians(), 0, 1, "mm", show), + "Count" => show_number(4.0, 0, 0, "mm", show), + "Area" => show_number(1.0, 2, 0, "mm", show), + _ => Err(DocError::Expr(format!("unknown parameter `{n}`"))), + }; + assert_eq!(eval("'Width: ' + Width", &look).unwrap(), "Width: 85 mm"); + assert_eq!(eval("Gap", &look).unwrap(), "0.5 in"); + assert_eq!(eval("Tilt + ' / ' + Count", &look).unwrap(), "30° / 4"); + assert_eq!(eval("number(Width) + 'x' + number( Width , 2 )", &look).unwrap(), "85x85.00"); + assert_eq!(eval("number(Tilt, 1)", &look).unwrap(), "30.0"); + for bad in ["number(Width", "number()", "number(Width, x)", "number(Width, 9)", "number(Width 2)", "Area", "number('a')"] { + assert!(eval(bad, &look).is_err(), "{bad}"); + } + assert_eq!(references("number(Width, 1) + Count"), ["Width", "Count"]); + assert_eq!(rename_reference("number(Width, 1) + Width + 'Width'", "Width", "W"), "number(W, 1) + W + 'Width'"); + assert_eq!(show_number(-0.0001, 1, 0, "mm", Show::Named).unwrap(), "0 mm"); + assert!(show_number(f64::NAN, 0, 0, "mm", Show::Named).is_err()); + } } diff --git a/crates/engine/src/batch_preflight.rs b/crates/engine/src/batch_preflight.rs index 188f87cf52..f16aef3dad 100644 --- a/crates/engine/src/batch_preflight.rs +++ b/crates/engine/src/batch_preflight.rs @@ -19,7 +19,7 @@ use serde_json::{Value, json}; use crate::schema::ParamCheck; -use crate::{CommandSpec, EngineError, find_command}; +use crate::{EngineError, find_command}; /// The first step a batch would refuse at its turn. #[derive(Clone, Debug, PartialEq)] @@ -57,11 +57,11 @@ pub fn check(steps: &[Value], level: ParamCheck, host: &[&str]) -> Option Option Option { steps.iter().enumerate().find_map(|(index, step)| { let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)?; - contract_refusal(index, id, find_command(id)?, step) + contract_refusal(index, id, step) }) } -/// Step `index`'s refusal when its parameters break `spec`'s typed contract. -fn contract_refusal(index: usize, id: &str, spec: &CommandSpec, step: &Value) -> Option { - let first = spec.contract?.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()?; +/// Step `index`'s refusal when its parameters break the typed contract of its command `id`. +fn contract_refusal(index: usize, id: &str, step: &Value) -> Option { + let first = crate::schema::issues(id, step.get("params").unwrap_or(&Value::Null)).into_iter().next()?; Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() }) } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 4ecc83297d..d588fd1471 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; -use solidcraft_doc::FeatureKind; +use solidcraft_doc::{BrowserEntry, FeatureKind, MAX_GROUP_NESTING}; use super::CommandSpec; use crate::params::{bad, bool_, str_}; @@ -24,11 +24,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"), CommandSpec::new("browser.group", "New Group", group) .icon("folder") - .params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"), - CommandSpec::new("browser.ungroup", "Ungroup", ungroup).icon("folder").params("group: id (its items go back to the folder)"), + .params("folder?: bodies|sketches|construction; component?: id (default 0); parent?: group id (the new group nests in it, taking its component and folder; at most 8 levels); name?; items?: [keys] (they leave other groups)"), + CommandSpec::new("browser.ungroup", "Ungroup", ungroup).icon("folder").params("group: id (its items and child groups go up to the group that holds it, or the folder)"), CommandSpec::new("browser.rename_group", "Rename Group", rename_group).icon("rename").params("group: id, name"), CommandSpec::new("browser.move", "Move Browser Items", move_items).icon("reorder") - .params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"), + .params("folder, component?, items: [keys (strings) and group ids (numbers)]; group?: id (into it; omit for the folder's top level); before?: key or group id (position in it; default the end). A group cannot go into itself or past 8 levels"), CommandSpec::new("document.units", "Change Design Units", document_units) .icon("settings") .params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"), @@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params( "view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm, about the up axis; yaw: heading of the line of sight, 0 along +Y (Front), π/2 along +X (Left), π (Back), −π/2 (Right); pitch: the eye's elevation, within ±π/2, π/2 = Top; roll defaults to 0, turns the picture clockwise, must be finite and is clamped to ±π; the View angles readout shows these in degrees; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step", ), - CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder, component?, order: [keys] (the folder's items in display order)"), + CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder: bodies|sketches|construction|components, component?, order: [keys (strings) and top-level group ids (numbers)] (display order; for `components`, the ids of the components placed in it)"), CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").params( "visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \ or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \ @@ -98,11 +98,102 @@ fn group_id(s: &Session, p: &Value, cmd: &str) -> Result { if s.doc.browser_groups.iter().any(|g| g.id == id) { Ok(id) } else { Err(bad(cmd, format!("no group {id}"))) } } +/// A list of groups (numbers) and items (strings): a number is always a group. +fn entries(p: &Value, k: &str, cmd: &str) -> Result> { + let list = match p.get(k) { + Some(Value::Array(a)) => a + .iter() + .map(|x| match x { + Value::String(s) => Some(BrowserEntry::Item(s.clone())), + _ => x.as_u64().map(BrowserEntry::Group), + }) + .collect::>>(), + None => Some(Vec::new()), + _ => None, + }; + let list = list.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys (strings) and group ids (numbers)")))?; + if list.len() > 10_000 || list.iter().any(|e| e.as_item().is_some_and(|k| k.is_empty() || k.len() > 256)) { + return Err(bad(cmd, format!("`{k}` has bad keys"))); + } + Ok(list) +} + +/// A number in a list of items or an order names a group; say so when there is none, as a sketch +/// or plane id passed as a number is the likely mistake (item keys are strings). +fn no_group(cmd: &str, id: u64) -> crate::EngineError { + bad(cmd, format!("no group {id} (a number is a group id; pass item keys as strings)")) +} + +/// The group a command names under `key`, if it names one (`null` or absent: none). +fn optional_group(s: &Session, p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a group id")))?; + if s.doc.browser_group(id).is_some() { Ok(Some(id)) } else { Err(no_group(cmd, id)) } + } + } +} + +/// The list a group's contents (or the folder's top level, `None`) are ordered by. +fn holder_list<'a>(d: &'a mut solidcraft_doc::Document, component: u64, folder: &str, holder: Option) -> Option<&'a mut Vec> { + match holder { + Some(g) => d.browser_groups.iter_mut().find(|x| x.id == g).map(|g| &mut g.items), + None => Some(d.browser_order.entry(format!("{component}/{folder}")).or_default()), + } +} + +/// Name every group under `holder` in its list, where they are drawn, so a position among them +/// can be found. +fn settle(d: &mut solidcraft_doc::Document, component: u64, folder: &str, holder: Option) { + let all = d.ordered_entries(component, folder, holder); + if let Some(list) = holder_list(d, component, folder, holder) { + *list = all; + } +} + +/// Take entries out of every list of a component's folder: its groups' and the top level's. +fn take_out(d: &mut solidcraft_doc::Document, component: u64, folder: &str, gone: &[BrowserEntry]) { + for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) { + g.items.retain(|e| !gone.contains(e)); + } + if let Some(list) = d.browser_order.get_mut(&format!("{component}/{folder}")) { + list.retain(|e| !gone.contains(e)); + if list.is_empty() { + d.browser_order.remove(&format!("{component}/{folder}")); + } + } +} + fn group(s: &mut Session, p: &Value) -> Result { let cmd = "browser.group"; - let folder = folder_of(p, cmd)?; - let component = component_of(s, p, cmd)?; - let items = keys(p, "items", cmd)?; + let parent = optional_group(s, p, "parent", cmd)?; + let (mut component, mut folder) = (None, None); + if let Some(g) = parent.and_then(|g| s.doc.browser_group(g)) { + // Omitted, they come from the parent; given, they must agree with it. + if p.get("component").is_some() && component_of(s, p, cmd)? != g.component { + return Err(bad(cmd, "`parent` belongs to another component")); + } + if p.get("folder").is_some() && folder_of(p, cmd)? != g.folder { + return Err(bad(cmd, "`parent` belongs to another folder")); + } + if s.doc.group_level(g.id) >= MAX_GROUP_NESTING { + return Err(bad(cmd, format!("groups nest at most {MAX_GROUP_NESTING} levels"))); + } + (component, folder) = (Some(g.component), Some(g.folder.clone())); + } + let folder = match folder { + Some(f) => f, + None => folder_of(p, cmd)?, + }; + let component = match component { + Some(c) => c, + None => component_of(s, p, cmd)?, + }; + let items = match entries(p, "items", cmd)?.into_iter().map(|e| e.as_item().map(str::to_string)).collect::>>() { + Some(items) => items, + None => return Err(bad(cmd, "`items` are item keys: move groups with browser.move")), + }; let d = s.doc_mut(); let id = d.allocate_id()?; let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) { @@ -110,16 +201,32 @@ fn group(s: &mut Session, p: &Value) -> Result { Some(_) => return Err(bad(cmd, "`name` is too long")), None => format!("Group{}", d.browser_groups.iter().filter(|g| g.folder == folder).count() + 1), }; - for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) { - g.items.retain(|k| !items.contains(k)); - } - d.browser_groups.push(solidcraft_doc::BrowserGroup { id, name: name.clone(), component, folder, items }); - Ok(json!({ "group": id, "name": name })) + let gone: Vec = items.iter().map(|k| BrowserEntry::item(k)).collect(); + take_out(d, component, &folder, &gone); + let items = gone; + d.browser_groups.push(solidcraft_doc::BrowserGroup { id, name: name.clone(), component, folder, parent, items }); + Ok(json!({ "group": id, "name": name, "parent": parent })) } +/// A group's contents go up to the group that holds it (the folder's top level for a top-level +/// group), in the place the group had; so do its child groups. Its own entry goes. fn ungroup(s: &mut Session, p: &Value) -> Result { let id = group_id(s, p, "browser.ungroup")?; - s.doc_mut().browser_groups.retain(|g| g.id != id); + let d = s.doc_mut(); + let Some(g) = d.browser_group(id).cloned() else { return Ok(json!({ "group": id })) }; + settle(d, g.component, &g.folder, Some(id)); + settle(d, g.component, &g.folder, g.parent); + let inside = d.browser_group(id).map(|g| g.items.clone()).unwrap_or_default(); + for child in d.browser_groups.iter_mut().filter(|c| c.parent == Some(id)) { + child.parent = g.parent; + } + if let Some(list) = holder_list(d, g.component, &g.folder, g.parent) + && let Some(at) = list.iter().position(|e| *e == BrowserEntry::Group(id)) + { + list.splice(at..=at, inside); + } + take_out(d, g.component, &g.folder, &[BrowserEntry::Group(id)]); + d.browser_groups.retain(|x| x.id != id); Ok(json!({ "group": id })) } @@ -138,43 +245,91 @@ fn move_items(s: &mut Session, p: &Value) -> Result { let cmd = "browser.move"; let folder = folder_of(p, cmd)?; let component = component_of(s, p, cmd)?; - let items = keys(p, "items", cmd)?; + let items = entries(p, "items", cmd)?; if items.is_empty() { - return Err(bad(cmd, "`items` must list item keys")); + return Err(bad(cmd, "`items` must list item keys or group ids")); } - let to = match p.get("group") { - Some(Value::Null) | None => None, - Some(_) => Some(group_id(s, p, cmd)?), - }; + let to = optional_group(s, p, "group", cmd)?; if let Some(g) = to && !s.doc.browser_groups.iter().any(|x| x.id == g && x.component == component && x.folder == folder) { return Err(bad(cmd, "that group is in another folder")); } - let before = str_(p, "before").map(str::to_string); - let d = s.doc_mut(); - for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) { - g.items.retain(|k| !items.contains(k)); - if Some(g.id) == to { - let at = before.as_ref().and_then(|b| g.items.iter().position(|k| k == b)).unwrap_or(g.items.len()); - for (i, k) in items.iter().enumerate() { - g.items.insert(at + i, k.clone()); - } + let to_level = to.map_or(0, |g| s.doc.group_level(g)); + for e in &items { + let BrowserEntry::Group(id) = e else { continue }; + let Some(g) = s.doc.browser_group(*id) else { return Err(no_group(cmd, *id)) }; + if g.component != component || g.folder != folder { + return Err(bad(cmd, format!("group {id} is in another folder"))); } + if to.is_some_and(|t| s.doc.group_within(t, *id)) { + return Err(bad(cmd, format!("group {id} cannot go inside itself"))); + } + if to_level + s.doc.group_height(*id) > MAX_GROUP_NESTING { + return Err(bad(cmd, format!("groups nest at most {MAX_GROUP_NESTING} levels"))); + } + } + let before = match p.get("before") { + None | Some(Value::Null) => None, + Some(Value::String(k)) => Some(BrowserEntry::Item(k.clone())), + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad(cmd, "`before` must be an item key or a group id"))?; + if s.doc.browser_group(id).is_none() { + return Err(no_group(cmd, id)); + } + Some(BrowserEntry::Group(id)) + } + }; + let d = s.doc_mut(); + // Where a group list is silent about its child groups, the ones it names come first: name + // them all, so `before` can be one of them. + settle(d, component, &folder, to); + take_out(d, component, &folder, &items); + for e in &items { + if let BrowserEntry::Group(id) = e + && let Some(g) = d.browser_groups.iter_mut().find(|g| g.id == *id) + { + g.parent = to; + } + } + if let Some(list) = holder_list(d, component, &folder, to) { + let at = before.as_ref().and_then(|b| list.iter().position(|e| e == b)).unwrap_or(list.len()); + list.splice(at..at, items.iter().cloned()); } Ok(json!({ "items": items, "group": to })) } fn order(s: &mut Session, p: &Value) -> Result { let cmd = "browser.order"; - let folder = folder_of(p, cmd)?; let component = component_of(s, p, cmd)?; - let order = keys(p, "order", cmd)?; + let components = str_(p, "folder") == Some("components"); + let folder = if components { "components".to_string() } else { folder_of(p, cmd)? }; + let order = entries(p, "order", cmd)?; + for e in &order { + match e { + // Sub-components, by placement id, all of them placed in this component. + BrowserEntry::Item(k) if components => { + let placed = k.parse::().ok().is_some_and(|o| s.doc.occurrences.iter().any(|x| x.id == o && x.parent == component)); + if !placed { + return Err(bad(cmd, "the `components` folder is ordered by the ids of the components placed in it")); + } + } + BrowserEntry::Item(_) => {} + BrowserEntry::Group(id) => match s.doc.browser_group(*id) { + None => return Err(no_group(cmd, *id)), + Some(g) if components || g.component != component || g.folder != folder || g.parent.is_some() => { + return Err(bad(cmd, format!("group {id} is not a top-level group of this folder"))); + } + Some(_) => {} + }, + } + } let key = format!("{component}/{folder}"); let d = s.doc_mut(); if order.is_empty() { d.browser_order.remove(&key); } else { + // An item that sits in a group belongs to the group's list only. d.browser_order.insert(key, order); } Ok(json!({})) @@ -1731,6 +1886,43 @@ mod tests { assert!(crate::view::sketch_shown(&s, wall) && !crate::view::sketch_shown(&s, base)); } + #[test] + fn a_hole_at_sketch_points_hides_its_sketch_as_an_extrude_does() { + // #9082: a Hole's points sketch stayed shown, and its points were drawn in the holes. A + // Single hole snapped to one point (#2110) leaves its sketch shown: it was placed on a face, + // and a hidden sketch offers no points for the next one to snap to. + let mut s = Session::default(); + let plate = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "PlateSketch"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": plate, "distance": 6})); + assert!(!crate::view::sketch_shown(&s, plate), "the Extrude used its sketch"); + let pts = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "HolePts", "offset": 6}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.point", json!({"point": [10, 10]})); + run(&mut s, "sketch.point", json!({"point": [30, 20]})); + run(&mut s, "sketch.finish", json!({})); + let spot = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Spot", "offset": 6}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.point", json!({"point": [20, 15], "id": "c"})); + run(&mut s, "sketch.finish", json!({})); + assert!(crate::view::sketch_shown(&s, pts) && crate::view::sketch_shown(&s, spot), "unused sketches are shown"); + run(&mut s, "solid.hole", json!({"sketch": "HolePts", "diameter": 3, "name": "Holes"})); + assert!(!crate::view::sketch_shown(&s, pts), "the Hole used its points sketch"); + run(&mut s, "solid.hole", json!({"sketch": "Spot", "points": ["c"], "single": true, "diameter": 3, "depth": 3, "name": "One"})); + assert!(crate::view::sketch_shown(&s, spot), "a Single hole leaves the sketch it snapped to shown"); + let d = run(&mut s, "document.inspect", json!({})); + let visible = |id: u64| d["sketches"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap()["visible"].clone(); + assert_eq!((visible(pts), visible(spot)), (json!(false), json!(true)), "{d}"); + // While the Hole is edited it is drawn, then hidden again; shown on its own, it stays drawn. + s.feature_edit_sketch = Some(pts); + assert!(crate::view::sketch_shown(&s, pts)); + s.feature_edit_sketch = None; + assert!(!crate::view::sketch_shown(&s, pts)); + run(&mut s, "browser.visibility", json!({"items": [pts], "visible": true})); + assert!(crate::view::sketch_shown(&s, pts) && !crate::view::sketch_shown(&s, plate)); + run(&mut s, "browser.visibility", json!({"items": [pts], "visible": false})); + assert!(!crate::view::sketch_shown(&s, pts)); + } + #[test] fn helper_sketches_hide_and_show() { let mut s = Session::default(); @@ -1985,17 +2177,17 @@ mod tests { let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap(); assert_eq!(s.doc.browser_groups[0].name, "Group1"); run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()], "group": g, "before": a.clone()})); - assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), a.clone()]); + assert_eq!(s.doc.browser_groups[0].items, vec![b.clone().into(), a.clone().into()]); run(&mut s, "browser.rename_group", json!({"group": g, "name": "Fasteners"})); run(&mut s, "body.rename", json!({"body": a, "name": "Bolt"})); - assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), "Bolt".to_string()]); + assert_eq!(s.doc.browser_groups[0].items, vec![b.clone().into(), "Bolt".into()]); // Out of the group, then a folder order; saved with the design. run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()]})); - assert_eq!(s.doc.browser_groups[0].items, vec!["Bolt".to_string()]); + assert_eq!(s.doc.browser_groups[0].items, vec!["Bolt".into()]); run(&mut s, "browser.order", json!({"folder": "bodies", "order": [b.clone(), "Bolt"]})); let saved: solidcraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap(); assert_eq!(saved.browser_groups, s.doc.browser_groups); - assert_eq!(saved.browser_order["0/bodies"], vec![b, "Bolt".to_string()]); + assert_eq!(saved.browser_order["0/bodies"], vec![b.into(), "Bolt".into()]); run(&mut s, "browser.ungroup", json!({"group": g})); assert!(s.doc.browser_groups.is_empty()); // Hostile input is refused. @@ -2178,4 +2370,107 @@ mod tests { let e = s.execute("browser.visibility", &json!({"visible": false, "features": ["Sphere1"], "path": [first]})).expect_err("other component"); assert!(e.to_string().contains("component"), "{e}"); } + + use solidcraft_doc::{BrowserEntry, MAX_GROUP_NESTING}; + + fn three_bodies(s: &mut Session) { + for (i, name) in ["A", "B", "C"].into_iter().enumerate() { + run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [i as f64 * 20.0, 0, 0], "body_name": name})); + } + } + + fn new_group(s: &mut Session, mut p: Value) -> u64 { + p["folder"] = json!("bodies"); + run(s, "browser.group", p)["group"].as_u64().unwrap() + } + + fn state(s: &Session) -> (Vec, std::collections::BTreeMap>) { + (s.doc.browser_groups.clone(), s.doc.browser_order.clone()) + } + + fn refused(s: &mut Session, id: &str, p: Value) { + let before = s.doc.to_json(); + let r = s.execute(id, &p); + assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{id} {p}: {r:?}"); + assert_eq!(s.doc.to_json(), before, "{id} {p}: a refused command changes nothing"); + } + + /// Nest a group, order groups and components, ungroup; each step undoes and redoes, and none of + /// it evaluates anything. + #[test] + fn groups_nest_reorder_and_ungroup_with_undo_redo_and_no_evaluation() { + let mut s = Session::default(); + three_bodies(&mut s); + let mut placed = Vec::new(); + for name in ["P", "Q"] { + placed.push(run(&mut s, "component.create", json!({"name": name}))["occurrence"].as_u64().unwrap()); + run(&mut s, "component.activate", json!({"component": 0})); + } + let mut states = vec![state(&s)]; + s.evaluations = 0; + let outer = new_group(&mut s, json!({"items": ["A"]})); + states.push(state(&s)); + // The inner group takes its component and folder from the parent. + let inner = run(&mut s, "browser.group", json!({"parent": outer, "items": ["B"]}))["group"].as_u64().unwrap(); + states.push(state(&s)); + let g = s.doc.browser_group(inner).unwrap(); + assert_eq!((g.parent, g.component, g.folder.as_str()), (Some(outer), 0, "bodies")); + run(&mut s, "browser.order", json!({"folder": "components", "order": [placed[1].to_string(), placed[0].to_string()]})); + states.push(state(&s)); + let mut shown = placed.clone(); + s.doc.sort_placements(0, &mut shown); + assert_eq!(shown, [placed[1], placed[0]]); + run(&mut s, "browser.order", json!({"folder": "bodies", "order": ["C", outer]})); + states.push(state(&s)); + run(&mut s, "browser.move", json!({"folder": "bodies", "items": [inner], "before": "C"})); + states.push(state(&s)); + assert_eq!(s.doc.ordered_entries(0, "bodies", None), [BrowserEntry::Group(inner), "C".into(), BrowserEntry::Group(outer)]); + run(&mut s, "browser.ungroup", json!({"group": outer})); + states.push(state(&s)); + // The group's items go where it was. + assert_eq!(s.doc.browser_order["0/bodies"], [BrowserEntry::Group(inner), "C".into(), "A".into()]); + for i in (0..states.len() - 1).rev() { + run(&mut s, "edit.undo", json!({})); + assert_eq!(state(&s), states[i], "undo to step {i}"); + } + for (i, expected) in states.iter().enumerate().skip(1) { + run(&mut s, "edit.redo", json!({})); + assert_eq!(state(&s), *expected, "redo to step {i}"); + } + assert_eq!(s.evaluations, 0, "changing only the browser evaluates nothing"); + } + + #[test] + fn nesting_and_ordering_that_cannot_stand_are_refused() { + let mut s = Session::default(); + three_bodies(&mut s); + let part = run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "component.activate", json!({"component": 0})); + let outer = new_group(&mut s, json!({"items": ["A"]})); + let inner = new_group(&mut s, json!({"parent": outer})); + // A group inside itself or its own child. + refused(&mut s, "browser.move", json!({"folder": "bodies", "items": [outer], "group": outer})); + refused(&mut s, "browser.move", json!({"folder": "bodies", "items": [outer], "group": inner})); + // Past eight levels, by a new group or by moving one with a child of its own. + let mut chain = vec![new_group(&mut s, json!({}))]; + while chain.len() < MAX_GROUP_NESTING { + chain.push(new_group(&mut s, json!({"parent": chain[chain.len() - 1]}))); + } + refused(&mut s, "browser.group", json!({"folder": "bodies", "parent": chain[MAX_GROUP_NESTING - 1]})); + let heavy = new_group(&mut s, json!({})); + new_group(&mut s, json!({"parent": heavy})); + refused(&mut s, "browser.move", json!({"folder": "bodies", "items": [heavy], "group": chain[MAX_GROUP_NESTING - 2]})); + // A parent that is missing, or in another folder or component. + refused(&mut s, "browser.group", json!({"folder": "bodies", "parent": 424242})); + refused(&mut s, "browser.group", json!({"folder": "sketches", "parent": outer})); + refused(&mut s, "browser.group", json!({"parent": outer, "component": part["component"]})); + // Orders and moves naming a group that cannot be there; a number is a group, not an item key. + refused(&mut s, "browser.order", json!({"folder": "bodies", "order": [inner]})); + refused(&mut s, "browser.order", json!({"folder": "bodies", "order": [424242]})); + refused(&mut s, "browser.move", json!({"folder": "bodies", "items": ["B"], "before": 424242})); + refused(&mut s, "browser.group", json!({"folder": "bodies", "items": [12]})); + // The components folder takes placements of that component, and nothing else. + refused(&mut s, "browser.order", json!({"folder": "components", "order": ["424242"]})); + refused(&mut s, "browser.order", json!({"folder": "components", "order": [outer]})); + } } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 6042da3f52..8ddb0f953a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -16,7 +16,8 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("timeline.paste", "Paste Features", paste).icon("paste").params( "translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \ component_path?: its placement (default: the active one, else its only one); the copies go at the marker, each where \ - its original is in the world (copies into their own component keep its coordinates); body transfers are left out", + its original is in the world (copies into their own component keep its coordinates); body transfers are left out; \ + from_design?: true to paste sketch features from the host's cross-design clipboard snapshot", ), ]; @@ -35,6 +36,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { 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(); s.clipboard = copied; + s.foreign_copy = Some(std::sync::Arc::new(crate::ForeignCopy::of_features(s))); Ok(json!({"copied": names})) } @@ -55,6 +57,11 @@ pub(in crate::cmd) fn explicit_planes(p: &mut PlaneRef) { fn paste(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.paste"; + match p.get("from_design") { + Some(Value::Bool(true)) => return super::clipboard_foreign::paste(s, p), + None | Some(Value::Bool(false)) => {} + _ => return Err(bad(cmd, "`from_design` must be true or false")), + } if s.clipboard.is_empty() { return Err(bad(cmd, "nothing copied (timeline.copy first)")); } @@ -249,6 +256,212 @@ mod tests { run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) } + fn dimensioned_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "base", "expression": "5 mm"})); + run(&mut s, "parameters.change", json!({"name": "width", "expression": "base * 4"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"})); + let width = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "width"}))["param"].as_str().unwrap().to_string(); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": format!("{width} / 2")})); + run(&mut s, "sketch.finish", json!({})); + s + } + + fn sketch_area(s: &Session, id: u64) -> f64 { + s.model.state().sketch(id).unwrap().profiles.iter().map(|p| p.area).sum() + } + + #[test] + fn foreign_sketch_paste_keeps_parameter_graph_snapshot_and_undo() { + let mut source = dimensioned_sketch(); + run(&mut source, "timeline.copy", json!({"features": ["S"]})); + let mut target = dimensioned_sketch(); + run(&mut target, "document.units", json!({"units": "in"})); + run(&mut target, "parameters.change", json!({"name": "base", "expression": "7 mm"})); + target.foreign_copy = source.foreign_copy.clone(); + run(&mut source, "parameters.change", json!({"name": "base", "expression": "30 mm"})); + drop(source); + let before = target.doc.to_json(); + let undo = target.undo.len(); + let result = run(&mut target, "timeline.paste", json!({"from_design": true})); + let id = result["pasted"][0]["id"].as_u64().unwrap(); + assert_eq!(result["pasted"][0]["name"], "S (2)"); + assert!((sketch_area(&target, id) - 200.).abs() < 1e-6); + assert_eq!(target.doc.param("base").unwrap().expr, "7 mm"); + assert_eq!(target.doc.param("base_2").unwrap().expr, "5 mm"); + assert_eq!(target.doc.param("width_2").unwrap().expr, "base_2 * 4"); + assert_eq!(target.undo.len(), undo + 1); + let after = target.doc.to_json(); + run(&mut target, "edit.undo", json!({})); + assert_eq!(target.doc.to_json(), before); + run(&mut target, "edit.redo", json!({})); + assert_eq!(target.doc.to_json(), after); + let second = run(&mut target, "timeline.paste", json!({"from_design": true}))["pasted"][0]["id"].as_u64().unwrap(); + assert!((sketch_area(&target, second) - 200.).abs() < 1e-6); + run(&mut target, "parameters.change", json!({"name": "base_2", "expression": "10 mm"})); + assert!((sketch_area(&target, id) - 800.).abs() < 1e-6); + assert!((sketch_area(&target, second) - 200.).abs() < 1e-6, "each paste owns its parameters"); + assert!(solidcraft_doc::Document::from_json(&target.doc.to_json()).is_ok()); + } + + #[test] + fn foreign_sketch_paste_preserves_world_position_in_a_placed_component() { + use solidcraft_geom::Vec3; + + let mut source = dimensioned_sketch(); + let arc = [Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), Vec3::new(-1., 0., 0.)]; + for space in [solidcraft_sketch::WireSpace::World, solidcraft_sketch::WireSpace::Component] { + source.doc_mut().sketch_mut(1).unwrap().add_drawn_arc_in(&arc, space).unwrap(); + } + source.refresh(); + run(&mut source, "timeline.copy", json!({"features": ["S"]})); + let mut target = Session::default(); + let made = run(&mut target, "component.create", json!({"name": "Target", "activate": false})); + let component = made["component"].as_u64().unwrap(); + let occurrence = made["occurrence"].as_u64().unwrap(); + run(&mut target, "occurrence.ground", json!({"occurrence": occurrence, "grounded": false})); + run(&mut target, "occurrence.move", json!({"occurrence": occurrence, "translate": [100, 20, 30], "axis": [0, 0, 1], "angle": "90 deg"})); + target.foreign_copy = source.foreign_copy.clone(); + let id = run(&mut target, "timeline.paste", json!({"from_design": true, "component": component, "component_path": [occurrence]}))["pasted"] + [0]["id"] + .as_u64() + .unwrap(); + let original = source.world_state().sketch(1).unwrap().plane; + let pasted = target.world_state().sketch(id).unwrap().plane; + assert!(pasted.origin.dist(original.origin) < 1e-6, "{original:?} vs {pasted:?}"); + assert!(pasted.x.dist(original.x) < 1e-6); + assert!(pasted.y.dist(original.y) < 1e-6); + assert!((sketch_area(&target, id) - 200.).abs() < 1e-6); + assert_eq!(target.doc.feature(id).unwrap().component, component); + for (original, pasted) in + source.world_state().sketch(1).unwrap().sketch.wires.iter().zip(&target.world_state().sketch(id).unwrap().sketch.wires) + { + for (a, b) in original.pts.iter().chain(&original.fit).zip(pasted.pts.iter().chain(&pasted.fit)) { + assert!(a.dist(*b) < 1e-6, "wire {:?}: {a:?} vs {b:?}", original.space); + } + let (a, b) = (original.arc.unwrap(), pasted.arc.unwrap()); + assert!(a.center.dist(b.center) < 1e-6); + assert!(a.normal.dist(b.normal) < 1e-6); + assert!((a.radius - b.radius).abs() < 1e-6); + } + let moved = run( + &mut target, + "timeline.paste", + json!({"from_design": true, "component": component, "component_path": [occurrence], "translate": [2, 3, 4]}), + )["pasted"][0]["id"] + .as_u64() + .unwrap(); + let frame = target.placed_document().path_transform(component, &[occurrence]).unwrap(); + let delta = solidcraft_doc::apply_vector(&frame, Vec3::new(2., 3., 4.)); + for (original, pasted) in + source.world_state().sketch(1).unwrap().sketch.wires.iter().zip(&target.world_state().sketch(moved).unwrap().sketch.wires) + { + for (a, b) in original.pts.iter().chain(&original.fit).zip(pasted.pts.iter().chain(&pasted.fit)) { + assert!((*a + delta).dist(*b) < 1e-6, "translated wire {:?}: {a:?} vs {b:?}", original.space); + } + } + } + + #[test] + fn foreign_sketch_paste_refuses_external_references_and_bad_parameters_atomically() { + let mut source = dimensioned_sketch(); + let mut target = dimensioned_sketch(); + // The same numeric ID in the target must never become the sketch's missing plane. + if let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &mut source.doc_mut().feature_mut(1).unwrap().kind { + *plane = solidcraft_doc::PlaneRef::Construction { feature: 1.into() }; + } + run(&mut source, "timeline.copy", json!({"features": ["S"]})); + target.foreign_copy = source.foreign_copy.clone(); + let before = target.doc.to_json(); + let undo = target.undo.len(); + let error = target.execute("timeline.paste", &json!({"from_design": true})).unwrap_err().to_string(); + assert!(error.contains("fixed plane"), "{error}"); + assert_eq!(target.doc.to_json(), before); + assert_eq!(target.undo.len(), undo); + for params in + [json!({"from_design":"yes"}), json!({"from_design":true,"translate":"bad"}), json!({"from_design":true,"component_path":["bad"]})] + { + assert!(target.execute("timeline.paste", ¶ms).is_err()); + assert_eq!(target.doc.to_json(), before); + } + } + + #[test] + fn foreign_sketch_paste_remaps_links_and_rejects_missing_source_sketches() { + let mut source = dimensioned_sketch(); + run(&mut source, "sketch.create", json!({"plane":"XY","offset":5,"name":"Projected"})); + run(&mut source, "sketch.project", json!({"refs":[{"sketch":1,"curve":"l1"}]})); + run(&mut source, "sketch.finish", json!({})); + let mut target = dimensioned_sketch(); + run(&mut source, "timeline.copy", json!({"features":["Projected"]})); + target.foreign_copy = source.foreign_copy.clone(); + let before = target.doc.to_json(); + let err = target.execute("timeline.paste", &json!({"from_design":true})).unwrap_err().to_string(); + assert!(err.contains("external projected geometry"), "{err}"); + assert_eq!(before, target.doc.to_json()); + run(&mut source, "timeline.copy", json!({"features":["S","Projected"]})); + target.foreign_copy = source.foreign_copy.clone(); + let result = run(&mut target, "timeline.paste", json!({"from_design":true})); + let first = result["pasted"][0]["id"].as_u64().unwrap(); + let second = result["pasted"][1]["id"].as_u64().unwrap(); + let copied = target.doc.sketch(second).unwrap(); + assert!(matches!(copied.links[0].source, solidcraft_sketch::LinkSource::SketchCurve { sketch, .. } if sketch == first)); + assert!(target.model.result(second).unwrap().error.is_none()); + } + + #[test] + fn foreign_sketch_paste_keeps_plane_input_units_and_text_parameters() { + let mut source = Session::default(); + run(&mut source, "document.units", json!({"units":"in"})); + run(&mut source, "parameters.add", json!({"name":"height","expression":"2","unit":""})); + run(&mut source, "parameters.add", json!({"name":"Label","expression":"ABC","unit":"text"})); + run(&mut source, "sketch.create", json!({"plane":"XY","offset":"height","name":"Text"})); + run(&mut source, "sketch.text", json!({"text_expression":"Label + '-X'","at":[0,0],"height":"5 mm"})); + run(&mut source, "sketch.finish", json!({})); + run(&mut source, "timeline.copy", json!({"features":["Text"]})); + let mut target = Session::default(); + run(&mut target, "parameters.add", json!({"name":"Label","expression":"other","unit":"text"})); + run(&mut target, "parameters.add", json!({"name":"d1","expression":"reserved","unit":"text"})); + target.foreign_copy = source.foreign_copy.clone(); + let id = run(&mut target, "timeline.paste", json!({"from_design":true}))["pasted"][0]["id"].as_u64().unwrap(); + assert!((target.model.state().sketch(id).unwrap().plane.origin.z - 50.8).abs() < 1e-9); + assert_eq!(target.doc.text_param("Label").unwrap().text, "other"); + assert_eq!(target.doc.text_param("Label_2").unwrap().text, "ABC"); + assert_eq!(target.doc.text_param("d1").unwrap().text, "reserved"); + assert!(target.doc.param("d1").is_none()); + let state = target.model.state(); + let text = &state.sketch(id).unwrap().sketch.links[0].source; + assert!(matches!(text, solidcraft_sketch::LinkSource::Text { text, .. } if text == "ABC-X")); + assert!(solidcraft_doc::Document::from_json(&target.doc.to_json()).is_ok()); + } + + #[test] + fn foreign_sketch_paste_copies_equation_parameters_but_not_the_local_variable() { + let mut source = Session::default(); + run(&mut source, "parameters.add", json!({"name":"height","expression":"3 mm"})); + run(&mut source, "sketch.create", json!({"plane":"XY","name":"Curve"})); + run(&mut source, "sketch.equation_curve", json!({"x":"t","y":"height + t * t","start":-2,"end":2})); + run(&mut source, "sketch.finish", json!({})); + run(&mut source, "timeline.copy", json!({"features":["Curve"]})); + let mut target = Session::default(); + run(&mut target, "parameters.add", json!({"name":"height","expression":"99 mm"})); + target.foreign_copy = source.foreign_copy.clone(); + let id = run(&mut target, "timeline.paste", json!({"from_design":true}))["pasted"][0]["id"].as_u64().unwrap(); + let state = target.model.state(); + let sketch = &state.sketch(id).unwrap().sketch; + let equation = &sketch.equations[0]; + assert_eq!(equation.x.as_deref(), Some("t")); + assert_eq!(equation.y, "height_2 + t * t"); + let curve = sketch.curves.iter().find(|c| c.id == equation.curve).unwrap(); + for point in curve.kind.point_ids() { + let p = sketch.points[point].pos; + assert!((p.y - 3. - p.x * p.x).abs() < 1e-8, "{p:?}"); + } + assert!(target.doc.param("t").is_none()); + } + /// Features pasted into a component placed elsewhere (offset and turned) are carried into its /// frame: the pasted body sits where the original is in the world, in that component, and the /// pasted sketch is named after the original. diff --git a/crates/engine/src/cmd/clipboard_foreign.rs b/crates/engine/src/cmd/clipboard_foreign.rs new file mode 100644 index 0000000000..50b6c48717 --- /dev/null +++ b/crates/engine/src/cmd/clipboard_foreign.rs @@ -0,0 +1,310 @@ +//! Independent sketch features pasted from the immutable clipboard of another design. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Value, json}; +use solidcraft_doc::{Document, Feature, FeatureKind, Mat, PlaneRef}; + +use crate::params::{bad, vec3}; +use crate::{Result, Sel, Session}; + +const CMD: &str = "timeline.paste"; + +/// References to geometry outside the copied sketches must never resolve against coincident +/// IDs or names in the destination. Fixed and expression-placed planes are self-contained. +fn independent_plane(plane: &PlaneRef, depth: usize) -> Result<()> { + if depth > 64 { + return Err(bad(CMD, "copied plane nesting exceeds 64")); + } + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => Ok(()), + PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } | PlaneRef::AtAngle { base, line: None, .. } => { + independent_plane(base, depth + 1) + } + _ => Err(bad(CMD, "the copied sketch's plane depends on model geometry; convert it to a fixed plane before pasting into another design")), + } +} + +fn independent_links(sketch: &solidcraft_sketch::Sketch, features: &BTreeSet, moved: bool) -> Result<()> { + use solidcraft_sketch::LinkSource as L; + for link in &sketch.links { + let supported = match &link.source { + L::Text { .. } | L::SketchAxis { .. } => true, + L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => features.contains(sketch), + L::Origin | L::Axis { .. } => !moved, + L::Plane { name } => !moved && solidcraft_geom::Plane::named(name).is_some(), + _ => false, + }; + if !supported { + return Err(bad( + CMD, + "the copied sketch has external projected geometry; unlink it or copy its source sketches before pasting into another design", + )); + } + } + Ok(()) +} + +fn free_parameter_name(base: &str, taken: &mut BTreeSet) -> Result { + for index in 1..=solidcraft_doc::MAX_PARAMS + 1 { + let name = if index == 1 { base.to_string() } else { format!("{base}_{index}") }; + if Document::param_name_problem(&name).is_none() && taken.insert(name.clone()) { + return Ok(name); + } + } + Err(bad(CMD, format!("no free name for copied parameter {base}"))) +} + +fn model_parameter_name(taken: &mut BTreeSet) -> Result { + for index in 1..=taken.len() + 1 { + let name = format!("d{index}"); + if taken.insert(name.clone()) { + return Ok(name); + } + } + Err(bad(CMD, "no free model parameter name")) +} + +/// Bring the complete expression dependency graph, including dimensions driven by another +/// dimension. Use the source definitions' units, never a destination parameter with the same name. +fn parameters(target: &mut Document, source: &Document, features: &[Feature], mapped: &mut BTreeMap) -> Result<()> { + let definitions: BTreeMap<_, _> = source.param_defs().into_iter().map(|p| (p.name.clone(), p)).collect(); + let mut pending: Vec = features.iter().flat_map(|f| f.kind.expressions()).flat_map(solidcraft_doc::expr::references).collect(); + let mut texts = BTreeSet::new(); + for feature in features { + if let FeatureKind::Sketch { sketch, .. } = &feature.kind { + pending.extend(sketch.constraints.iter().filter_map(|c| c.param.clone())); + let text_and_points = sketch + .links + .iter() + .filter_map(|l| match &l.source { + solidcraft_sketch::LinkSource::Text { exprs, .. } => Some(exprs.list()), + _ => None, + }) + .flatten() + .chain(sketch.points.iter().filter_map(|p| p.exprs.as_deref()).flat_map(solidcraft_sketch::PointExprs::list)); + pending.extend(text_and_points.flat_map(|(_, e)| solidcraft_doc::expr::references(e))); + for equation in &sketch.equations { + pending + .extend(equation.list().into_iter().flat_map(|(_, e)| solidcraft_doc::expr::references(e)).filter(|n| n != equation.variable())); + } + texts.extend(sketch.text_word_exprs().into_iter().flat_map(|e| solidcraft_doc::text_params::references(e))); + } + } + let mut used = BTreeSet::new(); + let mut work = 0; + while let Some(name) = pending.pop() { + work += 1; + if work > 100_000 { + return Err(bad(CMD, "copied parameter references exceed work limit")); + } + if used.insert(name.clone()) { + let p = definitions.get(&name).ok_or_else(|| bad(CMD, format!("copied sketch uses missing parameter {name}")))?; + pending.extend(solidcraft_doc::expr::references(&p.expr)); + } + } + if target.params.len() + target.text_params.len() + used.len() + texts.len() > solidcraft_doc::MAX_PARAMS { + return Err(bad(CMD, "too many copied parameters")); + } + let mut taken: BTreeSet<_> = target.param_defs().into_iter().map(|p| p.name).chain(target.text_params.iter().map(|p| p.name.clone())).collect(); + for name in &used { + if mapped.contains_key(name) { + continue; + } + let definition = definitions.get(name).ok_or_else(|| bad(CMD, "missing copied parameter definition"))?; + let model = source.param(name).is_none_or(|p| p.model); + let new = if model { + let new = model_parameter_name(&mut taken)?; + target.params.push(solidcraft_doc::Param { + name: new.clone(), + expr: definition.expr.clone(), + unit: definition.unit.clone(), + comment: String::new(), + model: true, + }); + new + } else { + let new = free_parameter_name(name, &mut taken)?; + let mut p = source.param(name).cloned().ok_or_else(|| bad(CMD, "missing copied user parameter"))?; + p.name = new.clone(); + target.params.push(p); + new + }; + mapped.insert(name.clone(), new); + } + for name in texts { + let mut p = source.text_param(&name).cloned().ok_or_else(|| bad(CMD, format!("copied sketch uses missing text parameter {name}")))?; + p.name = free_parameter_name(&name, &mut taken)?; + mapped.insert(name, p.name.clone()); + target.text_params.push(p); + } + for (old, new) in mapped.iter() { + if let Some(definition) = definitions.get(old) + && let Some(p) = target.params.iter_mut().find(|p| p.name == *new) + { + p.expr = solidcraft_doc::clone_expression(&definition.expr, mapped); + p.comment = source.param(old).map(|p| p.comment.clone()).or_else(|| source.param_comments.get(old).cloned()).unwrap_or_default(); + } + if let Some(comment) = source.param_comments.get(old) { + target.param_comments.insert(new.clone(), comment.clone()); + } + } + Ok(()) +} + +fn text_expression(source: &str, mapped: &BTreeMap) -> String { + let mut result = source.to_string(); + let mut temporary = Vec::new(); + for (i, (old, new)) in mapped.iter().enumerate() { + let mut token = format!("__paste_text_{i}"); + while source.contains(&token) || mapped.keys().chain(mapped.values()).any(|name| name == &token) { + token.push('_'); + } + result = solidcraft_doc::text_params::rename_reference(&result, old, &token); + temporary.push((token, new)); + } + for (token, new) in temporary { + result = solidcraft_doc::text_params::rename_reference(&result, &token, new); + } + result +} + +pub(super) fn paste(s: &mut Session, p: &Value) -> Result { + let copy = s.foreign_copy.clone().ok_or_else(|| bad(CMD, "nothing was copied in another design"))?; + if copy.features.is_empty() || copy.features.len() > 1000 { + return Err(bad(CMD, "copy 1…1000 sketches before pasting into another design")); + } + let component = match p.get("component") { + None => s.active_component, + Some(v) => { + let key = match v { + Value::String(name) => name.clone(), + Value::Number(n) => n.to_string(), + _ => return Err(bad(CMD, "`component` must be a component id or name")), + }; + s.doc.find_component(&key).ok_or_else(|| bad(CMD, format!("no component `{key}`")))? + } + }; + let path = match p.get("component_path") { + Some(v) => v + .as_array() + .filter(|a| a.len() <= 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?, + None if component == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(component)), + None => super::component::placing::placement_of(s, &s.placed_document(), component, None, "component_path", CMD)?.0, + }; + let placed = s.placed_document(); + let frame = placed.path_transform(component, &path).ok_or_else(|| bad(CMD, "that path is not a placement of the target"))?; + let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(CMD, "the target's frame is not invertible"))?; + drop(placed); + let mut shift = solidcraft_doc::IDENTITY; + if let Some(t) = p.get("translate") { + let t = vec3(t).ok_or_else(|| bad(CMD, "`translate` must be [x, y, z]"))?; + shift[3] = [t.x, t.y, t.z, 1.]; + } + let world_shift = solidcraft_doc::mat_mul(&frame, &solidcraft_doc::mat_mul(&shift, &into_target)); + let mut placed_source = (*copy.doc).clone(); + for occurrence in &mut placed_source.occurrences { + if let Some(m) = copy.pending_moves.get(&occurrence.id) { + occurrence.transform = *m; + } + } + let selected: BTreeSet<_> = copy.features.iter().map(|f| f.id).collect(); + let mut transforms: BTreeMap = BTreeMap::new(); + for feature in ©.features { + let FeatureKind::Sketch { plane, sketch } = &feature.kind else { + return Err(bad(CMD, "only sketch features can be pasted between designs; copy a component to include other feature history")); + }; + independent_plane(plane, 0)?; + let source_frame = placed_source + .path_transform(feature.component, &placed_source.first_path(feature.component)) + .ok_or_else(|| bad(CMD, "the copied sketch's component placement is missing"))?; + let transform = solidcraft_doc::mat_mul(&shift, &solidcraft_doc::mat_mul(&into_target, &source_frame)); + independent_links(sketch, &selected, !solidcraft_doc::is_identity(&transform))?; + transforms.insert(feature.id, transform); + } + let mut scratch = s.scratch(); + let mut mapped = BTreeMap::new(); + let mut ids = BTreeMap::new(); + let mut names = BTreeMap::new(); + let mut made = Vec::new(); + for feature in ©.features { + let taken = |name: &str| scratch.doc.features.iter().any(|f| f.name == name); + let name = if taken(&feature.name) { solidcraft_doc::copy_name(&feature.name, taken) } else { feature.name.clone() }; + let id = scratch.doc_mut().add_feature(feature.kind.clone(), Some(&name))?; + let mut taken: BTreeSet<_> = + scratch.doc.param_defs().into_iter().map(|p| p.name).chain(scratch.doc.text_params.iter().map(|p| p.name.clone())).collect(); + let text_names: BTreeSet<_> = scratch.doc.text_params.iter().map(|p| p.name.clone()).collect(); + if let Some(added) = scratch.doc_mut().feature_mut(id) { + for name in &mut added.param_names { + if text_names.contains(name) { + *name = model_parameter_name(&mut taken)?; + } + } + } + if let Some(added) = scratch.doc.feature(id) { + mapped.extend(feature.param_names.iter().cloned().zip(added.param_names.iter().cloned())); + } + ids.insert(feature.id, id); + names.insert(feature.name.clone(), name.clone()); + made.push(json!({"id": id, "name": name, "from": feature.name})); + } + parameters(scratch.doc_mut(), ©.doc, ©.features, &mut mapped)?; + let source_values = copy.doc.param_values().0; + for original in ©.features { + let mut feature = original.clone(); + let transform = transforms.get(&original.id).ok_or_else(|| bad(CMD, "missing copied sketch transform"))?; + // Nonliteral unitless length inputs are interpreted in their design's display units. + // Make that unit explicit before moving the expressions to a design with other units. + if copy.doc.units != s.doc.units { + for (_, e, kind) in feature.kind.inputs_mut() { + if kind == solidcraft_doc::expr::Kind::Length + && !solidcraft_doc::expr::is_literal(e) + && let Some((solidcraft_doc::expr::Kind::Length, scale)) = solidcraft_doc::expr::unit_info(copy.doc.units.trim()) + && solidcraft_doc::expr::eval_with(e, &|name| { + source_values.get(name).copied().ok_or_else(|| solidcraft_doc::DocError::Expr(format!("unknown parameter {name}"))) + })? + .unitless() + { + *e = format!("({e}) * {scale} mm").into(); + } + } + } + feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped)?; + if let FeatureKind::Sketch { plane, sketch } = &mut feature.kind { + // 3D wires carry their own coordinate space, independently of the sketch plane. + for wire in &mut sketch.wires { + let matrix = if wire.space == solidcraft_sketch::WireSpace::Component { transform } else { &world_shift }; + solidcraft_doc::map_wire(wire, matrix); + } + // Patterns and text words have expressions outside FeatureKind's numeric inputs. + for pattern in &mut sketch.patterns { + for e in pattern.count_expression.iter_mut().chain(pattern.expressions.values_mut()) { + *e = solidcraft_doc::clone_expression(e, &mapped); + } + } + for e in sketch.text_word_exprs_mut() { + *e = text_expression(e, &mapped); + } + if !solidcraft_doc::is_identity(transform) { + super::clipboard::explicit_planes(plane); + } + } + feature.kind.transform_geometry(transform); + let id = *ids.get(&original.id).ok_or_else(|| bad(CMD, "missing copied sketch id"))?; + let added = scratch.doc_mut().feature_mut(id).ok_or_else(|| bad(CMD, "missing pasted sketch"))?; + added.kind = feature.kind; + added.component = component; + added.suppressed = original.suppressed; + } + scratch.refresh(); + for id in ids.values() { + if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) { + return Err(bad(CMD, format!("the pasted sketch did not rebuild: {error}"))); + } + } + scratch.selection = ids.values().map(|id| Sel::Feature { id: *id }).collect(); + s.accept_computed(scratch); + Ok(json!({"pasted": made, "errors": [], "notices": []})) +} diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index bf9dbe9063..ac627653d5 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -294,6 +294,22 @@ fn row(s: &mut Session, p: &Value) -> Result { if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) { return Err(bad(cmd, "configuration name already exists")); } + let changes: Vec<_> = s + .doc + .configs + .columns + .iter() + .zip(&cells) + .filter_map(|(column, value)| match column { + Column::Param(name) => Some((name.clone(), value.clone())), + _ => None, + }) + .collect(); + if !changes.is_empty() { + // Check the final parameter graph without activating the row or rebuilding geometry. + let mut trial = s.doc.as_ref().clone(); + trial.change_params_atomic(&changes).map_err(|e| bad(cmd, e.to_string()))?; + } let t = &mut s.doc_mut().configs; let validated_name = rename.clone().unwrap_or_else(|| name.clone()); match idx { @@ -664,6 +680,52 @@ mod tests { assert!((volume(&mut s) - 2000.0).abs() < 1e-6); } + #[test] + fn configuration_rows_reject_invalid_parameter_expressions_without_changes() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"L", "expression":"10 mm"})); + run(&mut s, "config.configure", json!({"params":["L"]})); + let before = s.doc.clone(); + let undo_len = s.undo.len(); + for expression in ["abc", "1/", "1/0"] { + for params in [ + json!({"name":"Bad", "values":{"param:L":expression}}), + json!({"name":"Copy", "from":"Default", "values":{"L":expression}}), + json!({"name":"Default", "rename":"Renamed", "values":{"L":expression}}), + ] { + let error = s.execute("config.row", ¶ms).expect_err("invalid expressions must be rejected when saving a row"); + assert!(error.to_string().contains("parameter `L`"), "{params}: {error}"); + assert_eq!(s.doc, before, "{params}"); + assert_eq!(s.undo.len(), undo_len, "{params}"); + } + } + } + + #[test] + fn configuration_rows_validate_the_final_parameter_graph_without_activating_it() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"L", "expression":"10 mm"})); + run(&mut s, "parameters.add", json!({"name":"W", "expression":"L + 2 mm"})); + run(&mut s, "config.configure", json!({"params":["L", "W"]})); + let before = s.doc.clone(); + // Changing L first would make a cycle with W's current expression. The final row is valid. + run(&mut s, "config.row", json!({"name":"Reordered", "values":{"L":"W + 2 mm", "W":"20 mm"}})); + assert_eq!(s.doc.params, before.params); + assert_eq!(s.doc.configs.active, before.configs.active); + let saved = s.doc.clone(); + let undo_len = s.undo.len(); + assert!(s.execute("config.row", &json!({"name":"Cycle", "from":"Reordered", "values":{"W":"L"}})).is_err()); + assert_eq!(s.doc, saved); + assert_eq!(s.undo.len(), undo_len); + run(&mut s, "config.activate", json!({"row":"Reordered"})); + let (values, errors) = s.doc.param_values(); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(values["L"].v, 22.0); + assert_eq!(values["W"].v, 20.0); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, saved); + } + #[test] fn configured_feature_inputs_and_suppression_match_typed_cells() { let mut s = Session::default(); @@ -746,9 +808,7 @@ mod tests { // Bad input. assert!(s.execute("config.row", &json!({"name": "X", "values": {"nope": 1}})).is_err()); assert!(s.execute("config.activate", &json!({"row": "Missing"})).is_err()); - run(&mut s, "config.row", json!({"name": "Broken", "values": {"length": "1/"}})); - assert!(s.execute("config.activate", &json!({"row": "Broken"})).is_err()); - run(&mut s, "config.row", json!({"name": "Broken", "delete": true})); + assert!(s.execute("config.row", &json!({"name": "Broken", "values": {"length": "1/"}})).is_err()); run(&mut s, "config.column", json!({"feature": fillet, "delete": true})); assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1)); } diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs index b815b94aa5..400a21fb05 100644 --- a/crates/engine/src/cmd/construct.rs +++ b/crates/engine/src/cmd/construct.rs @@ -14,135 +14,267 @@ use solidcraft_geom::Vec3; use super::CommandSpec; use super::features::{add_feature, check_expr, plane_param, sketch_id}; use crate::params::{bad, req_expr, str_, vec3}; +use crate::schema::construct::{ + AxisEdgeParams, AxisNormalToFaceParams, AxisThroughCylinderParams, AxisTwoPlanesParams, AxisTwoPointsParams, MidplaneParams, + PlaneAlongPathParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams, PointAlongPathParams, + PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams, +}; +use crate::schema::features::FeatureResult; +use crate::schema::{TypedContract, output_schema_for}; use crate::{Result, Session}; -const REF: &str = +pub(crate) const REF: &str = "{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}"; +const AXIS_THROUGH_CYLINDER_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_NORMAL_TO_FACE_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_TWO_PLANES_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_TWO_POINTS_CONTRACT: TypedContract = TypedContract::new(); +const AXIS_EDGE_CONTRACT: TypedContract = TypedContract::new(); +const POINT_VERTEX_CONTRACT: TypedContract = TypedContract::new(); +const POINT_TWO_EDGES_CONTRACT: TypedContract = TypedContract::new(); +const POINT_THREE_PLANES_CONTRACT: TypedContract = TypedContract::new(); +const POINT_CENTER_CONTRACT: TypedContract = TypedContract::new(); +const POINT_EDGE_PLANE_CONTRACT: TypedContract = TypedContract::new(); +const POINT_ALONG_PATH_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_MIDPLANE_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_TWO_EDGES_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_THREE_POINTS_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_TANGENT_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_ALONG_PATH_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_PERPENDICULAR_CONTRACT: TypedContract = TypedContract::new(); + pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face) + CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", crate::checked!("construct.axis.through_cylinder", axis_face)) .at("SOLID", "CONSTRUCT") .icon("axis_through_cylinder") + .contract(&AXIS_THROUGH_CYLINDER_CONTRACT) + .output(output_schema_for::) .params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"), - CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal) + CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", crate::checked!("construct.axis.normal_to_face", axis_normal)) .at("SOLID", "CONSTRUCT") .icon("axis_normal_to_face") + .contract(&AXIS_NORMAL_TO_FACE_CONTRACT) + .output(output_schema_for::) .params("face: ref; point: ref (the axis goes through it, square to the face); name?"), - CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes) + CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", crate::checked!("construct.axis.two_planes", axis_planes)) .at("SOLID", "CONSTRUCT") .icon("axis_two_planes") + .contract(&AXIS_TWO_PLANES_CONTRACT) + .output(output_schema_for::) .params("a, b: plane or planar face refs; name?"), - CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points) + CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", crate::checked!("construct.axis.two_points", axis_points)) .at("SOLID", "CONSTRUCT") .icon("axis_two_points") + .contract(&AXIS_TWO_POINTS_CONTRACT) + .output(output_schema_for::) .params("a, b: point refs (vertices, sketch points, points); name?"), - CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge) + CommandSpec::new("construct.axis.edge", "Axis Through Edge", crate::checked!("construct.axis.edge", axis_edge)) .at("SOLID", "CONSTRUCT") .icon("axis_edge") + .contract(&AXIS_EDGE_CONTRACT) + .output(output_schema_for::) .params("edge: ref (a straight edge or sketch line); name?"), - CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at) + CommandSpec::new("construct.point.vertex", "Point At Vertex", crate::checked!("construct.point.vertex", point_at)) .at("SOLID", "CONSTRUCT") .icon("point_vertex") + .contract(&POINT_VERTEX_CONTRACT) + .output(output_schema_for::) .params("point: ref (vertex, sketch point, point); name?"), - CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges) + CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", crate::checked!("construct.point.two_edges", point_edges)) .at("SOLID", "CONSTRUCT") .icon("point_two_edges") + .contract(&POINT_TWO_EDGES_CONTRACT) + .output(output_schema_for::) .params("a, b: edge or line refs (where they meet, or come closest); name?"), - CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes) + CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", crate::checked!("construct.point.three_planes", point_planes)) .at("SOLID", "CONSTRUCT") .icon("point_three_planes") + .contract(&POINT_THREE_PLANES_CONTRACT) + .output(output_schema_for::) .params("a, b, c: plane or planar face refs; name?"), - CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center) + CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", crate::checked!("construct.point.center", point_center)) .at("SOLID", "CONSTRUCT") .icon("point_center") + .contract(&POINT_CENTER_CONTRACT) + .output(output_schema_for::) .params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"), - CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane) + CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", crate::checked!("construct.point.edge_plane", point_edge_plane)) .at("SOLID", "CONSTRUCT") .icon("point_edge_plane") + .contract(&POINT_EDGE_PLANE_CONTRACT) + .output(output_schema_for::) .params("edge: edge or line ref; plane: plane or planar face ref; name?"), - CommandSpec::new("construct.point.along_path", "Point Along Path", point_along) + CommandSpec::new("construct.point.along_path", "Point Along Path", crate::checked!("construct.point.along_path", point_along)) .at("SOLID", "CONSTRUCT") .icon("point_along_path") + .contract(&POINT_ALONG_PATH_CONTRACT) + .output(output_schema_for::) .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"), - CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid) + CommandSpec::new("construct.plane.midplane", "Midplane", crate::checked!("construct.plane.midplane", plane_mid)) .at("SOLID", "CONSTRUCT") .icon("plane_midplane") + .contract(&PLANE_MIDPLANE_CONTRACT) + .output(output_schema_for::) .params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"), - CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges) + CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", crate::checked!("construct.plane.two_edges", plane_edges)) .at("SOLID", "CONSTRUCT") .icon("plane_two_edges") + .contract(&PLANE_TWO_EDGES_CONTRACT) + .output(output_schema_for::) .params("a, b: straight edge or line refs in one plane; name?"), - CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points) + CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", crate::checked!("construct.plane.three_points", plane_points)) .at("SOLID", "CONSTRUCT") .icon("plane_three_points") + .contract(&PLANE_THREE_POINTS_CONTRACT) + .output(output_schema_for::) .params("a, b, c: point refs; name?"), - CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent) + CommandSpec::new("construct.plane.tangent", "Tangent Plane", crate::checked!("construct.plane.tangent", plane_tangent)) .at("SOLID", "CONSTRUCT") .icon("plane_tangent") + .contract(&PLANE_TANGENT_CONTRACT) + .output(output_schema_for::) .params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"), - CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along) + CommandSpec::new("construct.plane.along_path", "Plane Along Path", crate::checked!("construct.plane.along_path", plane_along)) .at("SOLID", "CONSTRUCT") .icon("plane_along_path") + .contract(&PLANE_ALONG_PATH_CONTRACT) + .output(output_schema_for::) .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"), - CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane_perpendicular").params( - "plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane", - ), + CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", crate::checked!("construct.plane.perpendicular", plane_perpendicular)) + .at("SOLID", "CONSTRUCT") + .icon("plane_perpendicular") + .contract(&PLANE_PERPENDICULAR_CONTRACT) + .output(output_schema_for::) + .params( + "plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane", + ), ]; +/// Whether [`model_axis_param`] reads `v` as an edge or face of the model: `{edge: …}`, +/// `{face: …}`, or as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it +/// kept. +pub(crate) fn is_model_axis(v: &Value) -> bool { + v.get("edge").is_some() || v.get("face").is_some() || matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face")) +} + /// An axis on an edge or round face of the model (`{edge: [x,y,z]}` or `{face: [x,y,z]}`, the /// point on it), with the line it gives now (`solidcraft_doc::construct::model_axis`); `None` /// for any other form of `axis`. pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { - // Also as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it kept. - let saved = |v: &Value| matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face")); - let Some(v) = v.filter(|v| v.get("edge").is_some() || v.get("face").is_some() || saved(v)) else { return Ok(None) }; + let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) }; let g = geo(s, Some(v), cmd, "axis")?; let (o, d) = solidcraft_doc::construct::model_axis(&s.world_state(), &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?; Ok(Some((g, o, d))) } +/// A reference as [`geo`] reads it before looking anything up in the design: which form it has. +pub(crate) enum GeoForm<'a> { + /// As saved (`{type, …}`), read whole. + Saved(GeoRef), + Face(Vec3), + Edge(Vec3), + Vertex(Vec3), + /// `{sketch, point|curve}`: the sketch as given, and the point or curve, when one is given. + Sketch { + sketch: &'a Value, + part: Option>, + }, + Point(Vec3), + /// `{plane: …}`'s value, or a bare string: a plane, as `plane_param` reads it. + Plane(&'a Value), + /// `{axis: X|Y|Z|name}`. + Axis(&'a str), + /// `{construct_point: name}`. + ConstructPoint(&'a str), +} + +/// A sketch reference's point or curve id. +pub(crate) enum SketchPart<'a> { + Point(&'a str), + Curve(&'a str), +} + +/// Why [`geo_form`] refuses a reference. +pub(crate) enum GeoFormError { + /// A saved form serde refuses, with its message. + Saved(String), + /// None of the forms. + Unknown, +} + +/// What [`geo`] says when a sketch reference has neither `point` nor `curve`. +pub(crate) const SKETCH_PART: &str = "a sketch reference needs `point` or `curve`"; + +/// The form [`geo`] reads `v` as. The first that fits wins, in this order: `type` (read whole), a +/// point at `face`, `edge` or `vertex`, `sketch` (whatever else it has), a point at `point`, +/// `plane` (whatever it holds), `axis` and `construct_point` as text, and a bare string. +pub(crate) fn geo_form(v: &Value) -> std::result::Result, GeoFormError> { + let p3 = |k: &str| v.get(k).and_then(vec3); + Ok(if v.get("type").is_some() { + GeoForm::Saved(serde_json::from_value::(v.clone()).map_err(|e| GeoFormError::Saved(e.to_string()))?) + } else if let Some(at) = p3("face") { + GeoForm::Face(at) + } else if let Some(at) = p3("edge") { + GeoForm::Edge(at) + } else if let Some(at) = p3("vertex") { + GeoForm::Vertex(at) + } else if let Some(sketch) = v.get("sketch") { + let part = match (str_(v, "point"), str_(v, "curve")) { + (Some(point), _) => Some(SketchPart::Point(point)), + (None, Some(curve)) => Some(SketchPart::Curve(curve)), + _ => None, + }; + GeoForm::Sketch { sketch, part } + } else if let Some(p) = p3("point") { + GeoForm::Point(p) + } else if let Some(plane) = v.get("plane") { + GeoForm::Plane(plane) + } else if let Some(a) = str_(v, "axis") { + GeoForm::Axis(a) + } else if let Some(n) = str_(v, "construct_point") { + GeoForm::ConstructPoint(n) + } else if v.is_string() { + GeoForm::Plane(v) + } else { + return Err(GeoFormError::Unknown); + }) +} + /// A reference from its JSON form. pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result { let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?; - let p3 = |k: &str| v.get(k).and_then(vec3); - let mut g = if v.get("type").is_some() { - serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))? - } else if let Some(at) = p3("face") { - GeoRef::Face { at, name: None } - } else if let Some(at) = p3("edge") { - GeoRef::Edge { at, name: None } - } else if let Some(at) = p3("vertex") { - GeoRef::Vertex { at, edges: None } - } else if let Some(sk) = v.get("sketch") { - let sketch = sketch_id(s, Some(sk), cmd, what)?; - match (str_(v, "point"), str_(v, "curve")) { - (Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() }, - (None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() }, - _ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))), - } - } else if let Some(p) = p3("point") { - GeoRef::Point { p } - } else if v.get("plane").is_some() { - GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? } - } else if let Some(a) = str_(v, "axis") { - if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) { - GeoRef::World { axis: a.to_ascii_uppercase() } - } else { - match s.doc.find_feature(a).map(|f| &f.kind) { - Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() }, - _ => return Err(bad(cmd, format!("no axis `{a}`"))), + let mut g = match geo_form(v) { + Ok(GeoForm::Saved(g)) => g, + Ok(GeoForm::Face(at)) => GeoRef::Face { at, name: None }, + Ok(GeoForm::Edge(at)) => GeoRef::Edge { at, name: None }, + Ok(GeoForm::Vertex(at)) => GeoRef::Vertex { at, edges: None }, + Ok(GeoForm::Sketch { sketch, part }) => { + let sketch = sketch_id(s, Some(sketch), cmd, what)?; + match part { + Some(SketchPart::Point(point)) => GeoRef::SketchPoint { sketch, point: point.to_string() }, + Some(SketchPart::Curve(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() }, + None => return Err(bad(cmd, format!("`{what}`: {SKETCH_PART}"))), } } - } else if let Some(n) = str_(v, "construct_point") { - match s.doc.find_feature(n).map(|f| &f.kind) { + Ok(GeoForm::Point(p)) => GeoRef::Point { p }, + Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? }, + Ok(GeoForm::Axis(a)) => { + if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) { + GeoRef::World { axis: a.to_ascii_uppercase() } + } else { + match s.doc.find_feature(a).map(|f| &f.kind) { + Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() }, + _ => return Err(bad(cmd, format!("no axis `{a}`"))), + } + } + } + Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) { Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() }, _ => return Err(bad(cmd, format!("no construction point `{n}`"))), - } - } else if v.as_str().is_some() { - // A bare string: a plane. - GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? } - } else { - return Err(bad(cmd, format!("`{what}` must be {REF}"))); + }, + Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))), + Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))), }; g.name_picks(&s.model.state()); Ok(g) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index db3d5732a0..9a38d94a29 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -12,7 +12,8 @@ use solidcraft_geom::Vec3; use super::{CommandSpec, not_in_sketch}; use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3}; use crate::schema::features::{ - BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, PipeParams, ScaleParams, SphereParams, TorusParams, + BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, + ScaleParams, SphereParams, TorusParams, }; use crate::schema::{TypedContract, output_schema_for}; use crate::{EngineError, Result, Session}; @@ -26,6 +27,8 @@ const CONE_CONTRACT: TypedContract = TypedContract::new(); const PIPE_CONTRACT: TypedContract = TypedContract::new(); const BOUNDING_SOLID_CONTRACT: TypedContract = TypedContract::new(); const SCALE_CONTRACT: TypedContract = TypedContract::new(); +const OFFSET_PLANE_CONTRACT: TypedContract = TypedContract::new(); +const PLANE_AT_ANGLE_CONTRACT: TypedContract = TypedContract::new(); pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude)) @@ -137,13 +140,17 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("draft") .params("faces: [[x,y,z] points, or face names from model.faces]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"), - CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset) + CommandSpec::new("construct.plane.offset", "Offset Plane", crate::checked!("construct.plane.offset", plane_offset)) .at("SOLID", "CONSTRUCT") .icon("plane") + .contract(&OFFSET_PLANE_CONTRACT) + .output(output_schema_for::) .params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr, or through: a point ref (vertex, sketch point, construction point) the plane goes through, parallel to base; name?"), - CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle) + CommandSpec::new("construct.plane.at_angle", "Plane at Angle", crate::checked!("construct.plane.at_angle", plane_angle)) .at("SOLID", "CONSTRUCT") .icon("plane_angle") + .contract(&PLANE_AT_ANGLE_CONTRACT) + .output(output_schema_for::) .params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle: expr; name?"), CommandSpec::new("solid.split_face", "Split Face", split_face) .at("SOLID", "MODIFY") @@ -1885,6 +1892,7 @@ fn move_body_feature(s: &mut Session, p: &Value) -> Result { )?; if copy { copy_keeps_appearance(s, &reply, &bodies, cmd)?; + super::print_face::copies_keep_print_faces(s, &reply, &bodies); } Ok(reply) } @@ -1946,19 +1954,24 @@ fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Opt _ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")), }, Some(o @ Value::Object(_)) => { - let origin = match o.get("origin") { - None => Vec3::ZERO, - Some(v) => vec3(v).ok_or_else(|| { - bad(cmd, "axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported") - })?, - }; - let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?; + let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?; Ok((origin, dir, None)) } _ => Err(bad(cmd, "`axis` is required")), } } +/// An axis line `{origin?, dir}` as [`named_axis_line`] reads it: the origin (default: the +/// origin) and the unit direction. +pub(crate) fn axis_line_object(o: &Value) -> std::result::Result<(Vec3, Vec3), &'static str> { + let origin = match o.get("origin") { + None => Vec3::ZERO, + Some(v) => vec3(v).ok_or("axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported")?, + }; + let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or("axis needs a non-zero `dir`")?; + Ok((origin, dir)) +} + /// A pattern feature, or for `components`, new occurrences of them at each placement. fn finish_pattern( s: &mut Session, @@ -2086,7 +2099,12 @@ fn mirror(s: &mut Session, p: &Value) -> Result { let plane = plane_param(s, p.get("plane"), cmd)?; let combine = bool_(p, "combine").unwrap_or(false); let (bodies, placements) = placed_bodies(s, bodies, cmd)?; - add_feature(s, p, FeatureKind::Mirror { features, plane, bodies: bodies.into_iter().map(BodyName).collect(), combine, placements }) + let reply = + add_feature(s, p, FeatureKind::Mirror { features, plane, bodies: bodies.iter().cloned().map(BodyName).collect(), combine, placements })?; + if !combine { + super::print_face::copies_keep_print_faces(s, &reply, &bodies); + } + Ok(reply) } /// Bodies picked where the viewport shows them: each world name ("Body1", or "Body1 (Occ:2)" @@ -2129,13 +2147,31 @@ pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> { /// A construction plane may not take an origin plane's name (`XY`, `Top`...): a plane /// parameter could then mean either. pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> { - if solidcraft_geom::Plane::named(name.trim()).is_some() { - return Err(bad( - cmd, - format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim()), - )); + match plane_name_problem(name) { + Some(why) => Err(bad(cmd, why)), + None => Ok(()), + } +} + +/// What [`check_plane_name`] says about `name`, or `None` when a construction plane may take it. +pub(crate) fn plane_name_problem(name: &str) -> Option { + solidcraft_geom::Plane::named(name.trim()).map(|_| { + format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim()) + }) +} + +/// The forms [`plane_param`] takes, for its messages. +pub(crate) const PLANE_FORMS: &str = + "XY, XZ, YZ, a construction plane name or id, or {origin, normal} (origin coordinates may be length expressions)"; + +/// The plane an explicit plane object gives through `origin`, as [`plane_param`] reads it: from +/// `x_dir` and `y_dir` when both are points, else from `normal`; `None` when they don't make one. +pub(crate) fn explicit_plane(o: &Value, origin: Vec3) -> Option { + match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) { + (Some(x), Some(y), _) => solidcraft_geom::Plane::new(origin, x, y), + (_, _, Some(n)) => solidcraft_geom::Plane::from_normal(origin, n), + _ => None, } - Ok(()) } /// A plane reference from a parameter: an origin plane, a construction plane (by name or @@ -2144,7 +2180,7 @@ pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> { /// plane reads it here. pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result { use solidcraft_doc::PlaneRef; - const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal} (origin coordinates may be length expressions)"; + const WANT: &str = PLANE_FORMS; let construction = |r: &str| match s.doc.find_feature(r) { Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { feature: f.id.into() }), Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))), @@ -2181,12 +2217,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result solidcraft_geom::Plane::new(here, x, y), - (_, _, Some(n)) => solidcraft_geom::Plane::from_normal(here, n), - _ => None, - }; - let plane = pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))?; + let plane = explicit_plane(o, here).ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))?; if origin.iter().all(|e| solidcraft_doc::expr::is_literal(e)) { return Ok(PlaneRef::Custom { plane }); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0ff5717d7b..9b47d6b447 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -18,6 +18,7 @@ use solidcraft_doc::Document; use solidcraft_io::Format; use super::CommandSpec; +use super::print_face; use crate::params::{bad, bool_, num, str_, string_list}; use crate::{EngineError, Result, Session}; @@ -694,6 +695,26 @@ fn export_to( Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")})) } +/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face. +#[cfg(not(target_arch = "wasm32"))] +fn export_laid_to( + s: &Session, + path: &str, + format: Format, + bodies: &[String], + parts: &[(String, Vec)], + quality: solidcraft_io::MeshQuality, +) -> Result { + let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone()); + let mut state = s.doc.painted(&s.world_state()); + let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts); + let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality)?; + solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut out = json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}); + laid.report(&mut out); + Ok(out) +} + fn export(s: &mut Session, p: &Value) -> Result { let cmd = "file.export"; let path = path_arg(p, cmd)?; @@ -1456,6 +1477,51 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from + /// it. One mesh body left the cache out for the whole model, so every feature, the boxes + /// too, was evaluated again on opening. + #[test] + fn a_design_with_a_mesh_body_keeps_its_saved_model() { + let dir = std::env::temp_dir().join(format!("solidcraft-mesh-cache-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let stl = dir.join("tetra.stl"); + let facets = [ + [[0, 0, 0], [0, 10, 0], [10, 0, 0]], + [[0, 0, 0], [10, 0, 0], [0, 0, 10]], + [[0, 0, 0], [0, 0, 10], [0, 10, 0]], + [[10, 0, 0], [0, 10, 0], [0, 0, 10]], + ]; + let mut text = String::from("solid tetra\n"); + for f in facets { + text += "facet normal 0 0 0\nouter loop\n"; + for [x, y, z] in f { + text += &format!("vertex {x} {y} {z}\n"); + } + text += "endloop\nendfacet\n"; + } + std::fs::write(&stl, text + "endsolid tetra\n").unwrap(); + let mut session = Session::new(Document::new("Boxes and a mesh")); + for x in [0, 40, 80] { + session.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "corner": [x, 0, 0], "operation": "new_body"})).unwrap(); + } + session.execute("file.insert_mesh", &json!({"path": stl})).unwrap(); + let triangles = |s: &Session| s.model.state().bodies.iter().filter_map(|b| b.body.triangle_mesh().map(|(_, t)| t.len())).collect::>(); + assert_eq!(triangles(&session), [4], "one mesh body"); + let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap(); + assert!(solidcraft_io::read_design_geometry(&bytes).is_some(), "the model is saved with the mesh body"); + let path = dir.join("boxes-mesh.solidcraft"); + std::fs::write(&path, &bytes).unwrap(); + let mut reopened = Session::default(); + reopened.execute("doc.open", &json!({"path": path})).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + assert_eq!(reopened.model.results.len(), session.model.results.len()); + assert!(reopened.model.results.iter().zip(&session.model.results).all(|(a, b)| a.ms == b.ms), "taken from the file, not evaluated"); + assert_eq!(triangles(&reopened), [4]); + let volume = |s: &mut Session| s.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap(); + let (saved, opened) = (volume(&mut session), volume(&mut reopened)); + assert!((opened - saved).abs() < 1e-9 && (saved - (3. * 8000. + 1000. / 6.)).abs() < 1e-6, "{opened} vs {saved}"); + } + /// A sheet-metal design keeps each sheet body once in its saved model, not again with every /// later feature's state: a design of many features after its sheet metal wrote the sheets /// over and over, and past the cache's size limit kept only some of its states. @@ -1499,6 +1565,54 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// A design's saved model keeps each set of sketch keys once, not again with every state: + /// every state wrote the key of each sketch before it, so a design of many sketches and many + /// features after them (5,484 features) passed the cache's size limit and kept only some of + /// its states. A set is written as the changes from the one before it; the model opens from + /// the file with every state (the keys read back: `eval_cost_tests`). + #[test] + fn a_saved_model_keeps_each_set_of_sketch_keys_once() { + let mut s = Session::new(Document::new("Sketches")); + let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap(); + for i in 0..6 { + run(&mut s, "sketch.create", json!({"plane":"XY","name":format!("Pad{i}")})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20 * i,0],"p1":[20 * i + 10,10]})); + run(&mut s, "solid.extrude", json!({"distance":5,"operation":"new"})); + } + for i in 0..10 { + run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[20 * i,40,0],"operation":"new"})); + } + let saved = saved_geometry(&s, &s.doc).unwrap(); + let features = saved.records["features"].as_array().unwrap(); + assert_eq!(features.len(), s.doc.features.len()); + assert!(features.iter().all(|f| f.get("state").is_some()), "every state kept"); + let states = saved.records["states"].as_array().unwrap(); + let sets = saved.records["sketch_key_sets"].as_array().unwrap(); + // One set per sketch: each later one adds that sketch's key to the one before it. + assert_eq!(sets.len(), 6, "{sets:?}"); + assert_eq!(sets[0]["set"].as_array().unwrap().len(), 1); + for (i, set) in sets.iter().enumerate().skip(1) { + assert_eq!( + (set["base"].as_u64(), set["set"].as_array().unwrap().len(), set["drop"].as_array().unwrap().len()), + (Some(i as u64 - 1), 1, 0) + ); + } + let last = sets.len() - 1; + assert_eq!( + states.iter().filter(|st| st["sketch_key_set"] == json!(last)).count(), + 2 + 10, + "the last sketch, its extrude and every box share one set" + ); + let path = std::env::temp_dir().join(format!("solidcraft-sketch-key-cache-{}.solidcraft", std::process::id())); + let bytes = solidcraft_io::write_design_container_with_cache(&s.doc, solidcraft_io::DesignCompression::Fast, None, Some(&saved)).unwrap(); + std::fs::write(&path, bytes).unwrap(); + let mut reopened = Session::default(); + reopened.execute("doc.open", &json!({"path": path})).unwrap(); + assert_eq!(reopened.model.last_recomputed, 0, "taken from the file, not evaluated"); + assert!(reopened.model.results.iter().zip(&s.model.results).all(|(a, b)| a.ms == b.ms)); + std::fs::remove_file(path).unwrap(); + } + #[test] #[ignore = "manual native cache save benchmark; generate issue303-step.json first"] fn benchmark_native_cache_save() { diff --git a/crates/engine/src/cmd/file_easyprint.rs b/crates/engine/src/cmd/file_easyprint.rs index ab87392697..64d71ea232 100644 --- a/crates/engine/src/cmd/file_easyprint.rs +++ b/crates/engine/src/cmd/file_easyprint.rs @@ -19,10 +19,12 @@ pub(super) struct Exported { pub fell_back: bool, /// The bodies that went in: the ones named, else the selected ones. pub bodies: Vec, + /// The bodies laid on their print face, and the ones that couldn't be. + pub laid: super::print_face::Laid, } -/// Export the bodies (the selected ones when none are named). A 3MF that can't be written is -/// written as an STL, and `fell_back` says so. +/// Export the bodies (the selected ones when none are named), each laid on its print face. A +/// 3MF that can't be written is written as an STL, and `fell_back` says so. pub(super) fn export_bodies(s: &Session, cmd: &str, named: &[String], format: Format) -> Result { let mut bodies = named.to_vec(); if bodies.is_empty() { @@ -31,7 +33,8 @@ pub(super) fn export_bodies(s: &Session, cmd: &str, named: &[String], format: Fo if bodies.is_empty() { return Err(bad(cmd, "select the bodies to send, or give `bodies`")); } - let doc = s.doc.painted(&s.world_state()); + let mut doc = s.doc.painted(&s.world_state()); + let laid = super::print_face::lay_on_print_faces(s, &mut doc, &bodies, &[]); let write = |format| solidcraft_io::export_with_metadata(&doc, &bodies, format, &s.doc.name, &s.doc.metadata); let mut fell_back = false; let (bytes, ext) = match (format, write(format)) { @@ -42,7 +45,7 @@ pub(super) fn export_bodies(s: &Session, cmd: &str, named: &[String], format: Fo } (_, Err(e)) => return Err(e.into()), }; - Ok(Exported { bytes, ext, fell_back, bodies }) + Ok(Exported { bytes, ext, fell_back, bodies, laid }) } pub(super) fn open_in_easyprint(s: &mut Session, p: &Value) -> Result { @@ -56,7 +59,7 @@ pub(super) fn open_in_easyprint(s: &mut Session, p: &Value) -> Result { if !bool_(p, "consent").unwrap_or(false) { return Err(bad(cmd, "this uploads the model to Printables' servers: ask the user, then pass `consent: true`")); } - let Exported { bytes, ext, fell_back, .. } = export_bodies(s, cmd, &string_list(cmd, p, "bodies")?, format)?; + let Exported { bytes, ext, fell_back, laid, .. } = export_bodies(s, cmd, &string_list(cmd, p, "bodies")?, format)?; if bytes.len() > MAX_BYTES { let mb = bytes.len().div_ceil(1_000_000); return Err(bad(cmd, format!("the model is {mb} MB and EasyPrint takes {} MB at most: send fewer bodies", MAX_BYTES / 1_000_000))); @@ -64,5 +67,7 @@ pub(super) fn open_in_easyprint(s: &mut Session, p: &Value) -> Result { let name = easyprint::file_name(&s.doc.name, ext); let size = bytes.len(); upload(&name, bytes).map_err(|e| bad(cmd, e))?; - Ok(json!({"file_name": name, "bytes": size, "format": ext, "stl_fallback": fell_back, "uploading": true})) + let mut out = json!({"file_name": name, "bytes": size, "format": ext, "stl_fallback": fell_back, "uploading": true}); + laid.report(&mut out); + Ok(out) } diff --git a/crates/engine/src/cmd/file_send.rs b/crates/engine/src/cmd/file_send.rs index 1ed3d64bcc..dd8788704e 100644 --- a/crates/engine/src/cmd/file_send.rs +++ b/crates/engine/src/cmd/file_send.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use solidcraft_io::Format; use super::easyprint::{Exported, export_bodies}; -use super::export_to; +use super::export_laid_to; use crate::params::bad; use crate::slicers::{self, Slicer, TargetKind}; use crate::{EngineError, Result, Session}; @@ -81,7 +81,7 @@ pub(super) fn send(s: &mut Session, r: Send<'_>) -> Result { return Err(bad(r.cmd, format!("{} does not take {}", target.name, format_name(r.format)))); } let mut out = match r.dest { - Dest::Path(path) => export_to(s, path, r.format, &r.bodies, &r.parts, r.quality)?, + Dest::Path(path) => export_laid_to(s, path, r.format, &r.bodies, &r.parts, r.quality)?, Dest::Folder => write_in_folder(s, &r)?, }; let path = out.get("path").and_then(Value::as_str).unwrap_or_default().to_string(); @@ -92,11 +92,13 @@ pub(super) fn send(s: &mut Session, r: Send<'_>) -> Result { /// Write the bodies into the slicer folder. fn write_in_folder(s: &mut Session, r: &Send<'_>) -> Result { use crate::slicer_files; - let Exported { bytes, ext, fell_back, bodies } = export_bodies(s, r.cmd, &r.bodies, r.format)?; + let Exported { bytes, ext, fell_back, bodies, laid } = export_bodies(s, r.cmd, &r.bodies, r.format)?; let dir = slicer_files::dir().ok_or_else(|| bad(r.cmd, "no per-user folder on this system"))?; let stem = slicer_files::stem(&s.doc.name, s.path.as_deref(), s.uid, &bodies); let (file, numbered) = slicer_files::write(&dir, &stem, ext, &bytes).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; - Ok(json!({"path": file.to_string_lossy(), "bytes": bytes.len(), "format": ext, "numbered": numbered, "stl_fallback": fell_back})) + let mut out = json!({"path": file.to_string_lossy(), "bytes": bytes.len(), "format": ext, "numbered": numbered, "stl_fallback": fell_back}); + laid.report(&mut out); + Ok(out) } /// Hand the file at `path` to `target`, starting it first if it has to be, and say what happened in diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index ac76790dcd..9728d0552d 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[ revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \ never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only", ), - CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).icon("inspect").noundo().params("sketch?: id|name (default active)"), + CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).at("SKETCH", "INSPECT").icon("inspect").noundo().params("sketch?: id|name (default active)"), CommandSpec::new("model.edges", "List Edges", model_edges).icon("inspect").noundo().params("body: name"), CommandSpec::new("model.faces", "List Faces", model_faces).icon("inspect").noundo().params( "body: name → per face: index, name (what face.delete, sheet.convert `face`, solid.shell … take in place of a point), exact, area, centroid (not always on the face), \ @@ -56,7 +56,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("target: appearance.chain target. Copies its resolved look to a transient session clipboard. No document change or dialogs."), CommandSpec::new("appearance.paste", "Paste Look", appearance_gestures::paste).icon("paste").key("Ctrl+Alt+V") .params("bodies/faces/components: appearance.assign targets. Applies the copied look atomically in one undo step; no dialogs."), - CommandSpec::new("appearance.favorite", "Favourite Look", appearance_gestures::favorite).icon("star").noundo().doc_only() + 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", @@ -240,7 +240,12 @@ fn sketch_json(s: &Session, id: u64) -> Option { .profiles .iter() .enumerate() - .map(|(i, p)| json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": p.outer_curves, "holes": p.hole_curves})) + // A loop records one entry per piece it walks, and a conic or spline is walked as many + // short pieces: each loop names a curve once, in loop order. + .map(|(i, p)| { + let holes: Vec> = p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(); + json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": solidcraft_sketch::distinct_curves(&p.outer_curves), "holes": holes}) + }) .collect(); Some(json!({ "id": id, diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 7279dd2e7f..f55cbfe992 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -104,8 +104,9 @@ fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, UnitVec3, f64)> { } /// The round shape of mesh edge `index` of body `b`: from the kernel's exact curve when it -/// is the same edge (its ends on the polyline), else fitted to the polyline's points. -fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool) -> Option { +/// is the same edge (its ends on the polyline), else fitted to the polyline's points; and +/// whether it is the exact one. +fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool) -> Option<(Shape, bool)> { let pts = m.edges.get(index)?; let tol = (m.bounds().diagonal().max(1.0) * 1e-6).max(1e-9); let exact = solidcraft_kernel::edge_circle(&b.body, index).filter(|c| { @@ -113,6 +114,7 @@ fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool let on = pts.iter().all(|p| (p.dist(c.center) - c.radius).abs() <= tol * 10.0); ends && on }); + let is_exact = exact.is_some(); let (center, axis, radius, angle) = match exact { Some(c) => (c.center, c.axis, c.radius, if whole { std::f64::consts::TAU } else { c.angle }), None => { @@ -123,7 +125,17 @@ fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool (center, axis, radius, angle) } }; - Some(Shape::Round { center, axis, radius, angle, planar: true, lo: 0.0, hi: 0.0 }) + Some((Shape::Round { center, axis, radius, angle, planar: true, lo: 0.0, hi: 0.0 }, is_exact)) +} + +/// The length of a measured curve: an exact circle's or arc's own (radius times angle, as its +/// circumference and arc length are), else that of the polyline drawn for it, which runs inside +/// a curve (#9086). +fn curve_len(shape: &Shape, exact: bool, polyline: f64) -> f64 { + match *shape { + Shape::Round { radius, angle, .. } if exact => radius * angle, + _ => polyline, + } } /// Points along a circle or arc (`angle` from `start` about `axis`). @@ -192,7 +204,8 @@ fn sketch_curve(ss: &solidcraft_doc::SolvedSketch, id: &str) -> Option { (b - a).normalized() } _ => { - json["length_mm"] = json!(polyline_len(&pts)); + // A sketch's circle or arc is exact. + json["length_mm"] = json!(curve_len(&shape, true, polyline_len(&pts))); add_round(&mut json, &shape); None } @@ -290,19 +303,18 @@ fn describe(s: &Session, sel: &Sel) -> Option { let pieces: Vec<&Vec> = rim.iter().filter_map(|&i| m.edges.get(i)).collect(); let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum(); let points = pieces.iter().flat_map(|p| densify(p)).collect(); - let mut json = json!({"type": "edge", "length_mm": len}); - let shape = edge_round(b, &m, index, true).unwrap_or(Shape::Other); + let (shape, exact) = edge_round(b, &m, index, true).unwrap_or((Shape::Other, false)); + let mut json = json!({"type": "edge", "length_mm": curve_len(&shape, exact, len)}); add_round(&mut json, &shape); return Some(Item { geo: Geo::Points(points), dir: None, json, shape, slot: None }); } let (a, z) = (*pts.first()?, *pts.last()?); let straight = (z - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(z)))); - let len = polyline_len(pts); - let mut json = json!({"type": "edge", "length_mm": len}); - let shape = match straight { - Some(_) => Shape::Line { a, b: z }, - None => edge_round(b, &m, index, false).unwrap_or(Shape::Other), + let (shape, exact) = match straight { + Some(_) => (Shape::Line { a, b: z }, false), + None => edge_round(b, &m, index, false).unwrap_or((Shape::Other, false)), }; + let mut json = json!({"type": "edge", "length_mm": curve_len(&shape, exact, polyline_len(pts))}); add_round(&mut json, &shape); Some(Item { geo: Geo::Points(densify(pts)), dir: straight, json, shape, slot: None }) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 747073d5a2..10f9990469 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ mod bom; mod browser; pub(crate) mod clearance; mod clipboard; +mod clipboard_foreign; mod compare; mod component; mod component_clone; @@ -36,12 +37,15 @@ mod face; mod features; mod features_more; mod join_pieces; +pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis}; pub(crate) use face::{FaceRefForm, face_ref_form}; pub(crate) use features::profiles as feature_profiles; -pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key}; +pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key}; pub use features::{auto_operation, extrude_reach, shown_scope}; pub use 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_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; @@ -75,6 +79,7 @@ mod kernel_heal_tests; mod parameter_sweep; mod parameters; mod plastic; +mod print_face; mod render; mod render_ui; #[cfg(test)] @@ -93,6 +98,7 @@ mod sketch_3d; mod sketch_canvas; pub mod sketch_clipboard; mod sketch_constraints; +mod sketch_construction; mod sketch_create; mod sketch_ends; mod sketch_equation; @@ -288,6 +294,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { let mut v: Vec<&'static CommandSpec> = Vec::new(); v.extend(sketch::COMMANDS.iter()); v.extend(sketch_create::COMMANDS.iter()); + v.extend(sketch_construction::COMMANDS.iter()); v.extend(sketch_equation::COMMANDS.iter()); v.extend(sketch_constraints::COMMANDS.iter()); v.extend(sketch_import::COMMANDS.iter()); @@ -327,6 +334,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(parameters::COMMANDS.iter()); v.extend(parameter_sweep::COMMANDS.iter()); v.extend(file::COMMANDS.iter()); + v.extend(print_face::COMMANDS.iter()); v.extend(metadata::COMMANDS.iter()); v.extend(inspect::COMMANDS.iter()); v.extend(compare::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 2580e8c7b9..0e1e65a922 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -979,7 +979,30 @@ fn keep_sampled_trace( } fn list_studies(s: &mut Session, _p: &Value) -> Result { - Ok(json!({"studies": s.doc.assembly.studies, "traces": associative::list(s)})) + let studies: Vec = s.doc.assembly.studies.iter().map(|m| study_json(s, m)).collect(); + Ok(json!({"studies": studies, "traces": associative::list(s)})) +} + +/// A study as `motion.list` gives it: key values in the units `motion.study` takes — degrees +/// for angles, mm for distances, like `joint.list`'s values — each key with its joint's name, +/// so a listed study can be fed back to `motion.study` unchanged. +fn study_json(s: &Session, m: &MotionStudy) -> Value { + let keys: Vec = m + .keys + .iter() + .map(|k| { + let joint = s.doc.assembly.joints.iter().find(|j| j.id == k.joint); + let angle = joint.is_some_and(|j| j.kind.is_angle(k.index)); + json!({ + "joint": k.joint, + "joint_name": joint.map(|j| j.name.clone()).unwrap_or_else(|| k.joint.to_string()), + "index": k.index, + "unit": if angle { "deg" } else { "mm" }, + "points": k.points.iter().map(|(step, v)| json!([step, if angle { v.to_degrees() } else { *v }])).collect::>(), + }) + }) + .collect(); + json!({"id": m.id, "name": m.name, "steps": m.steps, "keys": keys}) } fn delete_study(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index f4e88fb36e..558dbb48d9 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -639,6 +639,35 @@ fn motion_study_plays_and_exports() { assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0)); } +/// Regression (#9741): `motion.study` takes degrees for angle keys, so `motion.list` must give +/// the keys back in those same units — feeding a listed study straight back to `motion.study` +/// must not turn a 90° key into 1.57°. +#[test] +fn motion_list_round_trips_key_units_through_motion_study() { + let hinge_deg = |s: &mut Session| { + run(s, "joint.list", json!({}))["joints"] + .as_array() + .and_then(|a| a.iter().find(|j| j["name"] == "Hinge").cloned()) + .and_then(|j| j["values"][0]["value"].as_f64()) + .unwrap_or(f64::NAN) + }; + let mut s = hinged_plates(0.0); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 10, "keys": [{"joint": "Hinge", "points": [[0, 0], [10, 90]]}]})); + run(&mut s, "motion.play", json!({"study": "Turn", "step": 10})); + assert!((hinge_deg(&mut s) - 90.0).abs() < 1e-6, "playing step 10 gave {}°", hinge_deg(&mut s)); + let listed = run(&mut s, "motion.list", json!({})); + let key = &listed["studies"][0]["keys"][0]; + assert_eq!(key["joint_name"], "Hinge"); + assert_eq!(key["unit"], "deg"); + assert!((key["points"][1][1].as_f64().unwrap_or(f64::NAN) - 90.0).abs() < 1e-6, "{listed}"); + // The listed keys go back to `motion.study` unchanged and the study still plays 90°. + let keys = listed["studies"][0]["keys"].clone(); + run(&mut s, "motion.study", json!({"study": "Turn", "keys": keys})); + run(&mut s, "motion.play", json!({"study": "Turn", "step": 0})); + run(&mut s, "motion.play", json!({"study": "Turn", "step": 10})); + assert!((hinge_deg(&mut s) - 90.0).abs() < 1e-6, "the round-tripped study played {}°", hinge_deg(&mut s)); +} + #[test] fn exploded_views_show_without_changing_the_design() { let mut s = rail(); diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 1ab65dc6df..3f22f176bf 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("parameters.delete", "Delete Parameter", delete) .icon("delete") .params("name (refused while something uses it; the error lists the users)"), - CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).icon("star").params("name, favorite?: bool (default true)"), + CommandSpec::new("parameters.favorite", "Favorite Parameter", favorite).icon("star").params("name, favorite?: bool (default true)"), CommandSpec::new("parameters.move", "Move Parameter", move_param) .icon("reorder") .params("name, before | after: another user parameter; the user parameter is listed just before or after it"), diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index 4fcb624260..3c1fe01e16 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -1060,3 +1060,51 @@ fn a_definition_of_the_parameter_a_value_names_changes_it() { let r = run(&mut s, "expr.evaluate", json!({"expression": "q = w * 2", "kind": "length"})); assert_eq!((r["ok"].as_bool(), r["value"].as_f64(), r["defines"].as_str()), (Some(true), Some(60.0), Some("q")), "{r}"); } + +/// The error of the feature named `name` in the last evaluation. +fn feature_error(s: &Session, name: &str) -> Option { + s.model.results.iter().find(|r| r.name == name).and_then(|r| r.error.clone()) +} + +/// A sketch dimension set negative fails its sketch; the features built from it fail naming that +/// sketch, not as a sketch that must come earlier in the timeline (#9075). Their errors follow +/// the sketch's own (never an earlier one from the cache) and clear when the value is set back. +#[test] +fn a_feature_on_a_failed_sketch_names_the_sketch() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let out = run( + &mut s, + "sketch.draw.dimensioned", + json!({"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [20, 10]}, "dimensions": {"width": 40, "height": 20}}), + ); + let width = out["dimensions"]["width"]["param"].as_str().unwrap_or_default().to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + // A healthy sketch and its extrude after them. + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [50, 0], "p1": [60, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Sketch2", "distance": 5, "operation": "new"})); + assert!(rel(volume(&mut s), 40.0 * 20.0 * 5.0 + 500.0) < 1e-9); + + let r = run(&mut s, "parameters.change", json!({"name": width, "expression": "-10"})); + let sketch = feature_error(&s, "Sketch1").unwrap_or_default(); + assert!(sketch.contains("dimension value -10"), "{sketch}"); + let extrude = feature_error(&s, "Extrude1").unwrap_or_default(); + assert!(extrude.contains("`Sketch1`") && extrude.contains("dimension value -10"), "{extrude}"); + assert!(!extrude.contains("earlier in the timeline"), "{extrude}"); + assert_eq!((feature_error(&s, "Sketch2"), feature_error(&s, "Extrude2")), (None, None), "{r}"); + // Another failing value: its own error, not the earlier one from the cache. + run(&mut s, "parameters.change", json!({"name": width, "expression": "-5"})); + let extrude = feature_error(&s, "Extrude1").unwrap_or_default(); + assert!(extrude.contains("dimension value -5"), "{extrude}"); + // The failed sketch renamed: its new name. + run(&mut s, "timeline.rename", json!({"feature": "Sketch1", "name": "Outline"})); + let extrude = feature_error(&s, "Extrude1").unwrap_or_default(); + assert!(extrude.contains("`Outline`") && !extrude.contains("Sketch1"), "{extrude}"); + // Set back: both build again. + let r = run(&mut s, "parameters.change", json!({"name": width, "expression": "40"})); + assert_eq!(r["errors"], json!([]), "{r}"); + assert!(rel(volume(&mut s), 40.0 * 20.0 * 5.0 + 500.0) < 1e-9); +} diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index 95aae7da93..0a651c013a 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -2,7 +2,8 @@ //! the dimensions in the public standards tables (no vendor geometry). Each part is inserted as //! its own component, built by ordinary features (so it stays parametric and editable), with a //! cosmetic thread where it has one; at a hole it gets a rigid joint that puts its axis on the -//! hole's and seats it on the face. +//! hole's and seats it on the face. Inserted at several holes it is still one component, placed +//! once per hole, each placement with its own joint. //! //! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or //! washer, the top of a set screw or pin); the shank runs down −z. @@ -18,13 +19,15 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("parts.library", "Standard Parts", library).icon("library") .noundo() .params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"), - CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params( + CommandSpec::new("parts.fastener", "Insert Fastener", insert_atomic).at("SOLID", "INSERT").icon("fastener").params( "the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?", ), - CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params( + CommandSpec::new("parts.insert", "Insert Part", insert_atomic).icon("fastener").params( "family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \ length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \ - at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?", + at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \ + placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name? → component, occurrence, joint?, \ + and with at: occurrences, joints (one per hole, in pick order)", ), CommandSpec::new("parts.resize", "Change Part Size", resize_atomic).icon("scale") .params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"), @@ -514,18 +517,40 @@ fn length_param(p: &Value, cmd: &str) -> Result> { } } +/// Building the part and seating it runs many feature and joint commands: as in +/// [`resize_atomic`], they run on a scratch session and the insert is one undo step. +fn insert_atomic(s: &mut Session, p: &Value) -> Result { + let mut computed = s.scratch(); + let result = insert(&mut computed, p)?; + computed.undo.clear(); + computed.active_component = s.active_component; + computed.active_occurrence_path = s.active_occurrence_path.clone(); + computed.active_sketch = s.active_sketch.filter(|id| computed.doc.feature(*id).is_some()); + s.accept_computed(computed); + Ok(result) +} + fn insert(s: &mut Session, p: &Value) -> Result { let cmd = "parts.insert (Insert Fastener)"; let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string(); let size = str_(p, "size").ok_or_else(|| bad(cmd, "`size` is required"))?.to_string(); let sh = shape(&family, &size, length_param(p, cmd)?).map_err(|e| bad(cmd, e))?; // A pick on a hole's rim also touches its end face: step onto the wall just inside the rim. - let at = p.get("at").and_then(vec3).map(|a| hole_wall(s, a).unwrap_or(a)); - // The occurrence the hole belongs to, found before the part exists. - let host = match at { - Some(a) => Some(super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?), - None => None, - }; + // Each hole's occurrence is found before the part exists, so a pick off every body fails the + // insert before anything is made. + let mut holes = Vec::new(); + let mut seats: Vec<(u64, Vec3)> = Vec::new(); + for a in hole_picks(p, cmd)? { + let (a, seat) = hole_wall(s, a).unwrap_or((a, a)); + let host = super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?; + // A rim is drawn as more than one edge, and one click on it picks them all: the picks + // of one rim seat one part. + if seats.iter().any(|(h, q)| *h == host && (*q - seat).len() < 1e-3) { + continue; + } + seats.push((host, seat)); + holes.push((a, host)); + } let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh)); let active = s.active_component; // Hardware is fitted to other parts: it starts free even as the design's first part (#2804). @@ -536,15 +561,29 @@ fn insert(s: &mut Session, p: &Value) -> Result { set_identity(s, comp, identity(&family, &size, &sh), None); run(s, "component.activate", json!({"component": active}))?; let mut out = json!({"component": comp, "occurrence": occ, "name": name}); - if let (Some(a), Some(h)) = (at, host) { - // Seat it: its axis on the hole's, its seating plane on the hole's face, the shank going - // into the hole (the joint mates the part's −z with the face's outward z). - let j = run( - s, - "joint.create", - json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": occ, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": format!("{name} seat")}), - )?; - out["joint"] = j["joint"].clone(); + if !holes.is_empty() { + // The first hole has the placement the component came with; each further hole places the + // same part again. All are made before any is seated, while each is still at the part's + // own origin, where the joint below takes the part's seating point to be. + let mut occurrences = vec![json!(occ)]; + for _ in 1..holes.len() { + occurrences.push(run(s, "occurrence.copy", json!({"component": comp}))?["occurrence"].clone()); + } + let mut joints = Vec::new(); + for (i, ((a, h), o)) in holes.into_iter().zip(&occurrences).enumerate() { + // Seat it: its axis on the hole's, its seating plane on the hole's face, the shank + // going into the hole (the joint mates the part's −z with the face's outward z). + let seat = if i == 0 { format!("{name} seat") } else { format!("{name} seat {}", i + 1) }; + let j = run( + s, + "joint.create", + json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": seat}), + )?; + joints.push(j["joint"].clone()); + } + out["joint"] = joints.first().cloned().unwrap_or(Value::Null); + out["occurrences"] = Value::Array(occurrences); + out["joints"] = Value::Array(joints); } else if let Some(pt) = p.get("point").and_then(vec3) { // Placed at a point, its +z along `direction`. let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z); @@ -563,8 +602,28 @@ fn insert(s: &mut Session, p: &Value) -> Result { Ok(out) } -/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim. -fn hole_wall(s: &Session, a: Vec3) -> Option { +/// The most holes one insert seats a part in. +const MAX_HOLES: usize = 1000; + +/// The hole picks in `at`: one point, or a list of them. +fn hole_picks(p: &Value, cmd: &str) -> Result> { + let finite = |a: Vec3| a.x.is_finite() && a.y.is_finite() && a.z.is_finite(); + let one = |v: &Value| vec3(v).filter(|a| finite(*a)).ok_or_else(|| bad(cmd, "`at` must be [x,y,z] or a list of them")); + match p.get("at") { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::Array(list)) if list.first().is_some_and(Value::is_array) || list.is_empty() => { + if list.len() > MAX_HOLES { + return Err(bad(cmd, format!("`at` takes at most {MAX_HOLES} holes"))); + } + list.iter().map(one).collect() + } + Some(v) => Ok(vec![one(v)?]), + } +} + +/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim, and the +/// point on the hole's axis level with it (the same for every pick on one rim). +fn hole_wall(s: &Session, a: Vec3) -> Option<(Vec3, Vec3)> { let st = s.model.state(); let dirs = [Vec3::Z * -1.0, Vec3::Z, Vec3::X, Vec3::X * -1.0, Vec3::Y, Vec3::Y * -1.0]; for step in [0.2, 0.5, 1.0, 2.0] { @@ -576,7 +635,7 @@ fn hole_wall(s: &Session, a: Vec3) -> Option { let along = v.dot(c.axis); let on_wall = ((v - c.axis * along).len() - c.radius).abs() < 1e-3 && along > c.start && along < c.end; if on_wall { - return Some(q); + return Some((q, c.axis_point + c.axis * along)); } } } @@ -800,6 +859,54 @@ mod tests { assert!(s.model.state().threads.iter().any(|t| t.designation == "M8" && (t.pitch - 1.25).abs() < 1e-9)); } + /// Several holes in one insert: one component placed and seated once per hole, in one undo + /// step (#9341); two picks of one rim are one hole; a pick off every body fails the whole + /// insert. + #[test] + fn one_insert_seats_a_screw_in_each_hole() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"})); + let centres = [(8.0, 8.0), (32.0, 8.0), (32.0, 22.0), (8.0, 22.0)]; + for (x, y) in centres { + run(&mut s, "solid.hole", json!({"position": [x, y, 10], "diameter": 3.4})); + } + let (comps, occs, undo) = (s.doc.components.len(), s.doc.occurrences.len(), s.undo.len()); + // A hole off the plate fails the insert, and nothing is made. + let off = json!({"family": "socket_head_cap_screw", "size": "M3", "length": 8, "at": [[9.7, 8, 10], [100, 100, 100]]}); + assert!(s.execute("parts.insert", &off).is_err()); + assert_eq!((s.doc.components.len(), s.doc.occurrences.len()), (comps, occs)); + // Each rim picked at the top face. + // The first hole is picked twice, on both halves of its rim, as one click on a rim picks + // every edge of it: it still gets one screw. + let mut at: Vec<_> = centres.iter().map(|(x, y)| json!([x + 1.7, y, 10])).collect(); + at.insert(1, json!([centres[0].0 - 1.7, centres[0].1, 10])); + let r = run(&mut s, "parts.fastener", json!({"family": "socket_head_cap_screw", "size": "M3", "length": 8, "at": at})); + assert_eq!(s.doc.components.len(), comps + 1, "one part: {r}"); + let comp = r["component"].as_u64().unwrap_or(0); + let placed: Vec = r["occurrences"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + assert_eq!(placed.len(), 4, "{r}"); + assert!(placed.iter().all(|o| s.doc.occurrences.iter().any(|x| x.id == *o && x.component == comp)), "{r}"); + assert_eq!(r["joints"].as_array().map(|j| j.iter().filter(|j| j.is_number()).count()), Some(4), "{r}"); + assert_eq!(r["joint"], r["joints"][0]); + // Each placement sits in its own hole: the head flush on the top face, the shank down + // the hole's axis. + let st = s.world_state(); + let mut seated: Vec<(f64, f64)> = Vec::new(); + for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) { + let (lo, hi) = (b.mesh().bounds().min, b.mesh().bounds().max); + assert!((hi.z - 13.0).abs() < 1e-6 && (lo.z - 2.0).abs() < 1e-6, "{lo:?} {hi:?}"); + seated.push(((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0)); + } + assert_eq!(seated.len(), 4, "{seated:?}"); + for (x, y) in centres { + assert!(seated.iter().any(|c| (c.0 - x).abs() < 1e-6 && (c.1 - y).abs() < 1e-6), "no screw at ({x}, {y}): {seated:?}"); + } + // One undo takes them all away. + assert_eq!(s.undo.len(), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!((s.doc.components.len(), s.doc.occurrences.len()), (comps, occs)); + } + #[test] fn every_sketch_of_a_standard_part_is_fully_defined() { for (family, size, resized) in [ diff --git a/crates/engine/src/cmd/point_cloud.rs b/crates/engine/src/cmd/point_cloud.rs index 80e0b86185..78bfb0283b 100644 --- a/crates/engine/src/cmd/point_cloud.rs +++ b/crates/engine/src/cmd/point_cloud.rs @@ -3,7 +3,9 @@ use serde_json::{Value, json}; use solidcraft_doc::Mat; -use solidcraft_doc::point_cloud::{ColorBy, Colors, MAX_COORD, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale}; +use solidcraft_doc::point_cloud::{ + ColorBy, Colors, Filter, Keep, MAX_COORD, MAX_FILTERS, MAX_NAMES, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale, +}; use super::CommandSpec; use crate::params::{bad, bool_, str_}; @@ -13,16 +15,16 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("pointcloud.insert", "Insert Point Cloud", insert) .at("SOLID", "INSERT") .icon("import") - .params("path: .ply, .xyz or .pts file (its points join the design as a point cloud: drawn as points, never a body; a PLY's red, green and blue are its colours) → cloud, name, points, source_points, bounds, warnings; name?; units?: mm|cm|m|in|ft (the file's length unit, default mm); max_points?: most points kept (default 5000000, at most 20000000; a larger file is thinned evenly); center?: bool (move the cloud so the middle of its base sits at the origin); point_size?: pixels (0.5…16, default 2); color_by?: file|single|height (the file's colours, the default; one colour; or a ramp by height, blue low to red high); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); height_range?: [low, high] design Z in mm for the height ramp, or null to work it out from the points (all but the lowest and highest 2 %)"), + .params("path: .ply, .xyz or .pts file (its points join the design as a point cloud: drawn as points, never a body; a PLY's red, green and blue are its colours, its other vertex properties can colour and filter it) → cloud, name, points, source_points, bounds, warnings; name?; units?: mm|cm|m|in|ft (the file's length unit, default mm); max_points?: most points kept (default 5000000, at most 20000000; a larger file is thinned evenly); center?: bool (move the cloud so the middle of its base sits at the origin); point_size?: pixels (0.5…16, default 2); color_by?: file|single|height or a property's name (the file's colours, the default; one colour; a ramp by height, blue low to red high; or by any property of the file, a ramp over its values or a palette for whole numbers); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); color_range?: [low, high] the ramp runs between (design Z in mm by height), or null for the automatic range (by height all but the lowest and highest 2 % of the points; by a property the values it takes over what the filters keep); height_range?: the same as color_range; filters?: [{property, range: [low, high]} or {property, values: [whole numbers]} for a property with named values], or null (only what every filter keeps is shown)"), CommandSpec::new("pointcloud.edit", "Edit Point Cloud", edit) .icon("edit") - .params("cloud: id or name; name?; visible?; point_size?: pixels (0.5…16); color_by?: file|single|height (the file's colours, the default; one colour; or a ramp by height, blue low to red high); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); height_range?: [low, high] design Z in mm for the height ramp, or null to work it out from the points (all but the lowest and highest 2 %); units?: mm|cm|m|in|ft (the file's length unit: the points are scaled from the unit they came in as); matrix?: [12 or 16 numbers] (where the cloud sits: a rigid row-major 3x4 or 4x4 matrix, as occurrence.move takes it); tolerance? → cloud"), + .params("cloud: id or name; name?; visible?; point_size?: pixels (0.5…16); color_by?: file|single|height or a property's name (the file's colours, the default; one colour; a ramp by height, blue low to red high; or by any property of the file, a ramp over its values or a palette for whole numbers); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); color_range?: [low, high] the ramp runs between (design Z in mm by height), or null for the automatic range (by height all but the lowest and highest 2 % of the points; by a property the values it takes over what the filters keep); height_range?: the same as color_range; units?: mm|cm|m|in|ft (the file's length unit: the points are scaled from the unit they came in as); matrix?: [12 or 16 numbers] (where the cloud sits: a rigid row-major 3x4 or 4x4 matrix, as occurrence.move takes it); tolerance?; filters?: [{property, range: [low, high]} or {property, values: [whole numbers]} for a property with named values], or null (only what every filter keeps is shown) → cloud"), CommandSpec::new("pointcloud.delete", "Delete Point Cloud", delete).icon("delete").params("cloud: id or name → deleted"), CommandSpec::new("pointcloud.list", "Point Clouds", list) .icon("list") .noundo() .doc_only() - .params("→ clouds: [{cloud, name, file, points, source_points, units, visible, point_size, color_by, color, colored, height_range (the ramp's now, its own or worked out), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"), + .params("→ clouds: [{cloud, name, file, points, source_points, units, visible, point_size, color_by (file, single, height or a property's name), color, colored, properties: [{name, integer, span, names}], filters, color_range (the ramp's now, exact over what the filters keep), height_range (the ramp's own range or the automatic one by height), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"), ]; fn units_of(p: &Value, cmd: &str) -> Result> { @@ -64,18 +66,74 @@ fn color(p: &Value, cmd: &str) -> Result>> { } } -/// `color_by`: absent (`None`), or file, single or height. -fn color_by(p: &Value, cmd: &str) -> Result> { +/// `color_by`: absent (`None`), file, single or height, or the name of a property. +fn color_by(p: &Value, cmd: &str) -> Result)>> { match p.get("color_by") { None | Some(Value::Null) => Ok(None), - Some(v) => v.as_str().and_then(ColorBy::parse).map(Some).ok_or_else(|| bad(cmd, "`color_by` must be file, single or height")), + Some(Value::String(v)) => Ok(Some(match ColorBy::parse(v) { + Some(b) if b != ColorBy::Property => (b, None), + _ => (ColorBy::Property, Some(v.clone())), + })), + Some(_) => Err(bad(cmd, "`color_by` must be file, single, height or a property's name")), } } +/// `filters`: absent (`None`), null for none, or [{property, range: [low, high]} or +/// {property, values: [whole numbers]}]. +fn filters(p: &Value, cmd: &str) -> Result>> { + let refuse = || { + bad( + cmd, + format!( + "`filters` must be at most {MAX_FILTERS} {{property, range: [low, high]}} (lowest first) or {{property, values: [whole numbers]}}, or null" + ), + ) + }; + match p.get("filters") { + None => Ok(None), + Some(Value::Null) => Ok(Some(Vec::new())), + Some(Value::Array(a)) if a.len() <= MAX_FILTERS => a + .iter() + .map(|f| { + let property = f.get("property").and_then(Value::as_str).ok_or_else(refuse)?.to_string(); + if let Some(values) = f.get("values") { + let values = values.as_array().ok_or_else(refuse)?; + let whole = |v: &Value| v.as_i64().and_then(|n| i32::try_from(n).ok()); + let values = values.iter().map(whole).collect::>>().filter(|v| v.len() <= MAX_NAMES).ok_or_else(refuse)?; + return Ok(Filter { property, keep: Keep::Values { values } }); + } + match f.get("range").and_then(Value::as_array).map(|r| r.iter().map(Value::as_f64).collect::>>()) { + Some(Some(r)) if matches!(r.as_slice(), &[lo, hi] if lo.is_finite() && hi.is_finite() && lo <= hi) => { + Ok(Filter { property, keep: Keep::Range { range: [r[0], r[1]] } }) + } + _ => Err(refuse()), + } + }) + .collect::>>() + .map(Some), + Some(_) => Err(refuse()), + } +} + +/// A cloud colours and filters only by properties it has. +fn check_properties(c: &PointCloud, cmd: &str) -> Result<()> { + let names: Vec<&str> = c.property_names().collect(); + for n in c.color_property.iter().filter(|_| c.color_by == ColorBy::Property).chain(c.filters.iter().map(|f| &f.property)) { + if !names.contains(&n.as_str()) { + return Err(bad(cmd, format!("the point cloud has no property `{n}` (it has {})", names.join(", ")))); + } + } + // A set of values filters a label: a property with named whole numbers. + if let Some(f) = c.filters.iter().find(|f| matches!(f.keep, Keep::Values { .. }) && c.complex.names(&f.property).is_none()) { + return Err(bad(cmd, format!("`{}` has no named values: filter it with a range", f.property))); + } + Ok(()) +} + /// `height_range`: absent (`None`), null for the automatic one (`Some(None)`), or [low, high]. fn height_range(p: &Value, cmd: &str) -> Result>> { - let refuse = || bad(cmd, "`height_range` must be [low, high] heights in mm, lowest first, or null"); - match p.get("height_range") { + let refuse = || bad(cmd, "`height_range` (or `color_range`) must be [low, high], lowest first, or null"); + match p.get("color_range").or_else(|| p.get("height_range")) { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::Array(a)) => match a.iter().map(Value::as_f64).collect::>>().as_deref() { @@ -90,13 +148,18 @@ fn height_range(p: &Value, cmd: &str) -> Result>> { /// Apply `color_by`, `color` and `height_range`: a colour given alone means one colour, and /// `color: null` the file's. -fn set_colors(c: &mut PointCloud, by: Option, color: Option>, range: Option>) { +fn set_colors(c: &mut PointCloud, by: Option<(ColorBy, Option)>, color: Option>, range: Option>) { if let Some(k) = color { c.color = k.or(c.color); c.color_by = if k.is_some() { ColorBy::Single } else { ColorBy::File }; } - if let Some(b) = by { + if let Some((b, property)) = by { + if property != c.color_property && range.is_none() { + // Another property's values have another range. + c.height_range = None; + } c.color_by = b; + c.color_property = property.or(c.color_property.take().filter(|_| b == ColorBy::Property)); } if let Some(r) = range { c.height_range = r; @@ -172,8 +235,12 @@ fn insert(s: &mut Session, p: &Value) -> Result { color: None, height_range: None, visible: true, + complex: read.complex, + color_property: None, + filters: filters(p, cmd)?.unwrap_or_default(), }; set_colors(&mut cloud, colors.0, colors.1, colors.2); + check_properties(&cloud, cmd)?; if center && let Some((lo, hi)) = cloud.local_bounds() { cloud.transform[3] = [-(lo.x + hi.x) / 2.0, -(lo.y + hi.y) / 2.0, -lo.z, 1.0]; } @@ -207,6 +274,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { let i = find(s, p, cmd)?; let size = point_size(p, cmd)?; let colors = (color_by(p, cmd)?, color(p, cmd)?, height_range(p, cmd)?); + let filters = filters(p, cmd)?; let units = units_of(p, cmd)?; let placement = match p.get("matrix") { None | Some(Value::Null) => None, @@ -236,6 +304,10 @@ fn edit(s: &mut Session, p: &Value) -> Result { c.point_size = v; } set_colors(&mut c, colors.0, colors.1, colors.2); + if let Some(f) = filters { + c.filters = f; + } + check_properties(&c, cmd)?; if let Some(v) = bool_(p, "visible") { c.visible = v; } @@ -271,7 +343,13 @@ fn list(s: &mut Session, _: &Value) -> Result { "units": c.units, "visible": c.visible, "point_size": c.point_size, - "color_by": c.color_by.name(), + "color_by": c.colored_property().filter(|_| c.color_by == ColorBy::Property).unwrap_or(c.color_by.name()), + "properties": c + .property_names() + .map(|n| json!({"name": n, "integer": c.complex.integer(n), "span": c.span(n), "names": c.complex.names(n).map(|m| &m.values)})) + .collect::>(), + "filters": c.filters, + "color_range": solidcraft_doc::point_cloud_distribution::color_range(c, &solidcraft_doc::point_cloud_distribution::Geometry::of(c), &c.filters), "color": c.color, "colored": !c.colors.is_empty(), "height_range": c.height_range_now(), diff --git a/crates/engine/src/cmd/print_face.rs b/crates/engine/src/cmd/print_face.rs new file mode 100644 index 0000000000..f25448903c --- /dev/null +++ b/crates/engine/src/cmd/print_face.rs @@ -0,0 +1,417 @@ +//! Laying bodies on their print face for a slicer (#8429). The bodies being sent are copied (the +//! state the export writes is already a copy), and each copy whose body has a print face is +//! turned so the face points down, its centre of mass moved onto the world origin in X and Y, +//! and the face moved onto Z = 0: one rigid transform, applied once. The design and its timeline +//! are not touched. Each body is placed on its own, so several land on the origin together and +//! the slicer arranges them. + +use serde_json::Value; +use solidcraft_doc::{ModelState, PrintFace}; +use solidcraft_geom::Vec3; + +use super::CommandSpec; +use crate::params::{bad, str_}; +use crate::schema::print_face::PrintFaceResult; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("body.print_face", "Print Face", crate::checked!("body.print_face", print_face)) + .icon("print_face") + .key("Ctrl+B") + .contract(&PRINT_FACE_CONTRACT) + .output(crate::schema::output_schema_for::) + .params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint) lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")]; + +const PRINT_FACE_CONTRACT: crate::schema::TypedContract = crate::schema::TypedContract::new(); + +/// Mark or clear a body's print face. +fn print_face(s: &mut Session, p: &Value) -> Result { + let cmd = "body.print_face"; + let name = str_(p, "body").ok_or_else(|| bad(cmd, "give `body`"))?; + let point = match p.get("point") { + None | Some(Value::Null) => None, + Some(v) => Some(crate::params::vec3(v).ok_or_else(|| bad(cmd, "`point` must be [x, y, z] of numbers"))?), + }; + let world = s.world_state(); + let body = world.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?; + let key = body.definition_name().to_string(); + let face = match point { + None => { + s.doc_mut().print_faces.remove(&key); + None + } + Some(point) => { + let index = solidcraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face of the body at `point`"))?; + let target = s.doc.print_face_target(body, index).map_err(|e| bad(cmd, e.to_string()))?; + let face = target.name.clone(); + s.doc_mut().print_faces.insert(key, target); + Some(face) + } + }; + let reply = PrintFaceResult { body: name.to_string(), face, param_warnings: Vec::new() }; + serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string())) +} + +/// Which bodies were laid on their print face, and a warning for each that has one but was +/// exported as it lies. +#[derive(Debug, Default)] +pub(super) struct Laid { + pub bodies: Vec, + pub warnings: Vec, +} + +impl Laid { + /// Add what happened to an export's reply. + pub(super) fn report(self, out: &mut serde_json::Value) { + let Some(obj) = out.as_object_mut() else { return }; + if !self.bodies.is_empty() { + obj.insert("print_faces".into(), serde_json::json!(self.bodies)); + } + if !self.warnings.is_empty() { + obj.insert("warnings".into(), serde_json::json!(self.warnings)); + } + } +} + +/// Copies keep the print faces of the bodies they were made from (Move/Copy, Mirror), as they +/// keep their look: the copies are the bodies feature `reply` made, in the order of `sources`. +/// A copy whose face can't be matched is left without one. +pub(super) fn copies_keep_print_faces(s: &mut Session, reply: &Value, sources: &[String]) { + let Some(id) = reply.get("feature").and_then(Value::as_u64) else { return }; + let state = s.model.state(); + let copies: Vec<_> = state.bodies.iter().filter(|b| b.feature == id).collect(); + if copies.len() != sources.len() { + return; + } + let mut inherited = Vec::new(); + for (source, copy) in sources.iter().zip(copies) { + let Some(source) = state.body(source) else { continue }; + if let Some(Ok(target)) = s.doc.copied_print_face(&state, source, copy) { + inherited.push((copy.definition_name().to_string(), target)); + } + } + for (body, target) in inherited { + s.doc_mut().print_faces.insert(body, target); + } +} + +/// Lay the bodies of `state` named in `bodies` (all of them when it is empty) on their print faces. +/// A body that is one of several parts of a 3MF object (`parts`, `objects: component`) stays as it +/// lies, so the object keeps its shape: turning one part on its own would pull it out of the rest. +pub(super) fn lay_on_print_faces(s: &Session, state: &mut ModelState, bodies: &[String], parts: &[(String, Vec)]) -> Laid { + let mut laid = Laid::default(); + let mut moves = Vec::new(); + for (i, b) in state.bodies.iter().enumerate() { + if !bodies.is_empty() && !bodies.contains(&b.name) { + continue; + } + if let Some((object, _)) = parts.iter().find(|(_, members)| members.contains(&b.name)) { + if s.doc.print_faces.contains_key(b.definition_name()) { + laid.warnings.push(format!("`{}` was exported as it lies: it is a part of the object `{object}`, which keeps its shape", b.name)); + } + continue; + } + let face = match s.doc.print_face(state, b) { + None => continue, + Some(Ok(face)) => face, + Some(Err(e)) => { + laid.warnings.push(format!("`{}` was exported as it lies: {e}", b.name)); + continue; + } + }; + let centre = match solidcraft_kernel::measure_mass(&b.body) { + Ok(m) => m.centroid, + Err(e) => { + laid.warnings.push(format!("`{}` was exported as it lies: no centre of mass ({e})", b.name)); + continue; + } + }; + moves.push((i, bed_matrix(face, centre))); + } + for (i, m) in moves { + let Some(b) = state.bodies.get_mut(i) else { continue }; + match solidcraft_kernel::transform_matrix(&b.body, m) { + Ok(body) => { + b.body = body; + laid.bodies.push(b.name.clone()); + } + Err(e) => laid.warnings.push(format!("`{}` was exported as it lies: {e}", b.name)), + } + } + laid +} + +/// The rotation (rows) that turns unit vector `n` to −Z. A face that already points down is +/// left as it is; one that points straight up is turned half a turn about X. +fn turn_down(n: Vec3) -> [Vec3; 3] { + let down = Vec3::new(0., 0., -1.); + let c = n.dot(down); + let v = n.cross(down); + let s2 = v.len2(); + if s2 <= 1e-24 { + return if c > 0. { [Vec3::X, Vec3::Y, Vec3::Z] } else { [Vec3::X, -Vec3::Y, -Vec3::Z] }; + } + // Rodrigues: R e = e + v × e + v × (v × e) (1 − c) / |v|²; these are R's columns. + let k = (1. - c) / s2; + let [x, y, z] = [Vec3::X, Vec3::Y, Vec3::Z].map(|e| e + v.cross(e) + v.cross(v.cross(e)) * k); + [Vec3::new(x.x, y.x, z.x), Vec3::new(x.y, y.y, z.y), Vec3::new(x.z, y.z, z.z)] +} + +/// The column-major transform that lays `face` on the bed with `centre` above the origin. +fn bed_matrix(face: PrintFace, centre: Vec3) -> [[f64; 4]; 4] { + let r = turn_down(face.normal); + let apply = |p: Vec3| Vec3::new(r[0].dot(p), r[1].dot(p), r[2].dot(p)); + let c = apply(centre); + let z = apply(face.point).z; + let t = Vec3::new(-c.x, -c.y, -z); + [[r[0].x, r[1].x, r[2].x, 0.], [r[0].y, r[1].y, r[2].y, 0.], [r[0].z, r[1].z, r[2].z, 0.], [t.x, t.y, t.z, 1.]] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + /// A box `L` x 10 x 5 (L = 20 mm) from the origin, called Block. + fn block() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "L", "expression": "20 mm"})); + run(&mut s, "solid.box", json!({"length": "L", "width": 10, "height": 5, "corner": [0, 0, 0], "body_name": "Block"})); + s + } + + /// Lay every body of `s` on its print face, as a slicer export does: Block's box and centre + /// of mass afterwards, and what was reported. + fn laid(s: &Session) -> (solidcraft_geom::Aabb3, Vec3, Laid) { + let mut state = s.doc.painted(&s.world_state()); + let laid = lay_on_print_faces(s, &mut state, &[], &[]); + let block = state.bodies.iter().find(|b| b.name == "Block").expect("Block"); + let m = solidcraft_kernel::measure_mass(&block.body).expect("measure"); + (m.bbox, m.centroid, laid) + } + + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 + } + + #[test] + fn a_part_of_a_3mf_object_keeps_its_place_in_the_object() { + // With `objects: component` the bodies of a component are the parts of one object: + // turning one of them on its own would pull it out of the others, so it stays as it lies. + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]})); + let mut state = s.doc.painted(&s.world_state()); + let parts = [("Bracket".to_string(), vec!["Block".to_string(), "Other".to_string()])]; + let laid = lay_on_print_faces(&s, &mut state, &[], &parts); + assert!(laid.bodies.is_empty(), "{:?}", laid.bodies); + assert_eq!(laid.warnings.len(), 1, "{:?}", laid.warnings); + assert!(laid.warnings.first().is_some_and(|w| w.contains("Block") && w.contains("Bracket")), "{:?}", laid.warnings); + let block = state.bodies.iter().find(|b| b.name == "Block").expect("Block"); + let bbox = solidcraft_kernel::measure_mass(&block.body).expect("measure").bbox; + assert!(near(bbox.min.x, 0.) && near(bbox.max.x, 20.) && near(bbox.max.z, 5.), "{bbox:?}"); + } + + #[test] + fn a_side_face_is_laid_down_at_z_0_with_the_centre_of_mass_over_the_origin() { + let mut s = block(); + let reply = run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]})); + assert!(reply["face"].is_string(), "{reply}"); + let (bbox, centre, laid) = laid(&s); + assert_eq!(laid.bodies, ["Block"]); + assert!(laid.warnings.is_empty(), "{:?}", laid.warnings); + // The 10 x 5 end face is on the bed, so the 20 mm length now stands up. + assert!(near(bbox.min.z, 0.) && near(bbox.max.z, 20.), "{bbox:?}"); + assert!(near(centre.x, 0.) && near(centre.y, 0.) && near(centre.z, 10.), "{centre:?}"); + assert!(near((bbox.max.x - bbox.min.x) * (bbox.max.y - bbox.min.y), 50.), "{bbox:?}"); + // The design itself has not moved. + let m = solidcraft_kernel::measure_mass(&s.model.state().bodies[0].body).expect("measure"); + assert!(near(m.bbox.min.x, 0.) && near(m.bbox.max.x, 20.) && near(m.bbox.max.z, 5.), "{:?}", m.bbox); + } + + #[test] + fn a_face_that_points_up_is_turned_over() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]})); + let (bbox, centre, laid) = laid(&s); + assert_eq!(laid.bodies, ["Block"]); + assert!(near(bbox.min.z, 0.) && near(bbox.max.z, 5.), "{bbox:?}"); + assert!(near(bbox.max.x - bbox.min.x, 20.) && near(bbox.max.y - bbox.min.y, 10.), "{bbox:?}"); + assert!(near(centre.x, 0.) && near(centre.y, 0.) && near(centre.z, 2.5), "{centre:?}"); + } + + #[test] + fn the_print_face_follows_the_body_through_a_rebuild() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]})); + run(&mut s, "parameters.change", json!({"name": "L", "expression": "30 mm"})); + let (bbox, centre, laid) = laid(&s); + assert!(laid.warnings.is_empty(), "{:?}", laid.warnings); + assert!(near(bbox.min.z, 0.) && near(bbox.max.z, 30.), "{bbox:?}"); + assert!(near(centre.x, 0.) && near(centre.y, 0.) && near(centre.z, 15.), "{centre:?}"); + } + + #[test] + fn without_a_print_face_the_body_is_exported_as_it_lies() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]})); + let reply = run(&mut s, "body.print_face", json!({"body": "Block"})); + assert!(reply.get("face").is_none(), "{reply}"); + assert!(s.doc.print_faces.is_empty()); + let (bbox, _, laid) = laid(&s); + assert!(laid.bodies.is_empty() && laid.warnings.is_empty()); + assert!(near(bbox.min.x, 0.) && near(bbox.max.x, 20.) && near(bbox.min.z, 0.) && near(bbox.max.z, 5.), "{bbox:?}"); + } + + #[test] + fn a_print_face_that_was_cut_away_is_reported_and_the_body_exported_as_it_lies() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]})); + // A cut takes the top 2 mm off: the marked face is gone; the new top is the cut's. + run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 5, "corner": [-5, -5, 3], "operation": "cut"})); + let (bbox, _, laid) = laid(&s); + assert!(laid.bodies.is_empty()); + assert_eq!(laid.warnings.len(), 1, "{:?}", laid.warnings); + assert!(laid.warnings[0].contains("Block"), "{:?}", laid.warnings); + assert!(near(bbox.min.x, 0.) && near(bbox.max.z, 3.), "{bbox:?}"); + } + + #[test] + fn a_print_face_is_saved_with_the_design_and_an_older_design_has_none() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]})); + let saved = serde_json::to_value(&*s.doc).expect("save"); + assert!(saved["print_faces"]["Block"].is_object(), "{}", saved["print_faces"]); + let opened: solidcraft_doc::Document = serde_json::from_value(saved.clone()).expect("open"); + assert_eq!(opened.print_faces, s.doc.print_faces); + let mut older = saved; + older.as_object_mut().expect("object").remove("print_faces"); + let opened: solidcraft_doc::Document = serde_json::from_value(older).expect("open"); + assert!(opened.print_faces.is_empty()); + } + + #[test] + fn renaming_the_body_keeps_its_print_face() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]})); + run(&mut s, "body.rename", json!({"body": "Block", "name": "Lid"})); + assert!(s.doc.print_faces.contains_key("Lid") && !s.doc.print_faces.contains_key("Block")); + assert_eq!(s.doc.print_faces["Lid"].body, "Lid"); + } + + /// Every body laid on its print face: (name, box, centre of mass) of those laid. + fn all_laid(s: &Session) -> Vec<(String, solidcraft_geom::Aabb3, Vec3)> { + let mut state = s.doc.painted(&s.world_state()); + let laid = lay_on_print_faces(s, &mut state, &[], &[]); + assert!(laid.warnings.is_empty(), "{:?}", laid.warnings); + laid.bodies + .iter() + .map(|name| { + let b = state.bodies.iter().find(|b| &b.name == name).expect("laid body"); + let m = solidcraft_kernel::measure_mass(&b.body).expect("measure"); + (name.clone(), m.bbox, m.centroid) + }) + .collect() + } + + #[test] + fn a_copy_keeps_the_print_face_of_the_body_it_was_copied_from() { + let mut s = block(); + run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]})); + run(&mut s, "solid.move", json!({"bodies": ["Block"], "translate": [0, 50, 0], "copy": true})); + assert_eq!(s.doc.print_faces.len(), 2, "{:?}", s.doc.print_faces.keys().collect::>()); + let laid = all_laid(&s); + assert_eq!(laid.len(), 2, "{laid:?}"); + for (name, bbox, centre) in &laid { + assert!(near(bbox.min.z, 0.) && near(bbox.max.z, 20.), "{name}: {bbox:?}"); + assert!(near(centre.x, 0.) && near(centre.y, 0.), "{name}: {centre:?}"); + } + } + + #[test] + fn a_mirrored_copy_keeps_the_mirrored_print_face() { + let mut s = block(); + // The end at X = 20; its mirror image in YZ is the copy's end at X = -20. + run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]})); + run(&mut s, "solid.mirror", json!({"bodies": ["Block"], "plane": "YZ"})); + assert_eq!(s.doc.print_faces.len(), 2, "{:?}", s.doc.print_faces.keys().collect::>()); + let copy = s.doc.print_faces.keys().find(|k| *k != "Block").expect("the copy's print face").clone(); + let world = s.world_state(); + let body = world.body(©).expect("copy"); + let face = s.doc.print_face(&world, body).expect("marked").expect("found"); + assert!(near(face.point.x, -20.) && near(face.normal.x, -1.), "{face:?}"); + let laid = all_laid(&s); + assert_eq!(laid.len(), 2, "{laid:?}"); + for (name, bbox, _) in &laid { + assert!(near(bbox.min.z, 0.) && near(bbox.max.z, 20.), "{name}: {bbox:?}"); + } + } + + #[test] + fn a_copy_of_a_body_without_a_print_face_gets_none() { + let mut s = block(); + run(&mut s, "solid.move", json!({"bodies": ["Block"], "translate": [0, 50, 0], "copy": true})); + run(&mut s, "solid.mirror", json!({"bodies": ["Block"], "plane": "XZ"})); + assert!(s.doc.print_faces.is_empty()); + } + + #[test] + fn a_point_off_the_body_or_a_body_that_is_not_there_is_refused() { + let mut s = block(); + assert!(s.execute("body.print_face", &json!({"body": "Block", "point": [100, 100, 100]})).is_err()); + assert!(s.execute("body.print_face", &json!({"body": "Nope", "point": [10, 5, 5]})).is_err()); + assert!(s.execute("body.print_face", &json!({"body": "Block", "point": "top"})).is_err()); + assert!(s.doc.print_faces.is_empty()); + } + + fn turned(n: Vec3) -> Vec3 { + let r = turn_down(n); + Vec3::new(r[0].dot(n), r[1].dot(n), r[2].dot(n)) + } + + #[test] + fn every_normal_is_turned_to_point_down_by_a_rotation() { + let down = Vec3::new(0., 0., -1.); + for n in [Vec3::X, -Vec3::X, Vec3::Y, -Vec3::Y, Vec3::Z, -Vec3::Z, Vec3::new(1., 2., 3.), Vec3::new(-0.3, 0.1, 0.9), Vec3::new(1e-9, 0., 1.)] + { + let n = n.normalized().unwrap(); + assert!(turned(n).dist(down) < 1e-12, "{n:?} → {:?}", turned(n)); + let r = turn_down(n); + // Orthonormal rows with a positive determinant: a rotation, never a mirror. + for i in 0..3 { + assert!((r[i].len() - 1.).abs() < 1e-12); + for j in i + 1..3 { + assert!(r[i].dot(r[j]).abs() < 1e-12); + } + } + assert!((r[0].cross(r[1]).dot(r[2]) - 1.).abs() < 1e-12); + } + } + + #[test] + fn a_face_that_points_straight_up_is_turned_half_a_turn_about_x() { + let r = turn_down(Vec3::Z); + assert_eq!(r, [Vec3::X, -Vec3::Y, -Vec3::Z]); + } + + #[test] + fn the_bed_matrix_puts_the_face_at_z_0_and_the_centre_over_the_origin() { + let face = PrintFace { index: 0, point: Vec3::new(3., 4., 10.), normal: Vec3::Z }; + let m = bed_matrix(face, Vec3::new(3., 4., 5.)); + let apply = |p: Vec3| { + Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0], + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1], + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2], + ) + }; + assert!(apply(face.point).z.abs() < 1e-12); + let c = apply(Vec3::new(3., 4., 5.)); + assert!(c.x.abs() < 1e-12 && c.y.abs() < 1e-12 && c.z > 0., "{c:?}"); + } +} diff --git a/crates/engine/src/cmd/render.rs b/crates/engine/src/cmd/render.rs index 71b6699362..de8a6f6973 100644 --- a/crates/engine/src/cmd/render.rs +++ b/crates/engine/src/cmd/render.rs @@ -74,7 +74,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("render.camera.get", "Inspect Render Camera", get_camera).icon("camera").noundo() .params("No parameters. Returns the explicitly defined current view camera, or null if the session has no camera override. No camera is fitted, no job is started and no saved setup or engineering document is changed."), CommandSpec::new("render.camera.fit", "Frame Render Subject", fit_camera).icon("camera").noundo() - .params("No parameters. Fits the current camera to the visible product bounds through the shared Camera::fit contract; changes only view state, not a saved setup, rendering job, stage or engineering geometry."), + .params("aspect?: the picture's width over its height, 0.000001 to 1000000 (default 1). Fits the current camera to the visible product bounds through the shared Camera::fit contract, moving back for a picture taller than wide so its sides hold the product too; changes only view state, not a saved setup, rendering job, stage or engineering geometry."), CommandSpec::new("render.preset.resolve", "Review Native Studio", studio_presets::resolve).icon("render_scene").noundo() .params("id: NATIVE-STUDIO-001..025; name; set?: typed GPU setup patch; expected_revision?:u64. Resolves original built-in HDR/rig/passive ground with conservative geometry clearance, returns all replacements and preserved fields without mutation. Unbound SCENE-* concepts are not accepted."), CommandSpec::new("render.preset.apply", "Apply Native Studio", studio_presets::apply).icon("render_scene") @@ -169,13 +169,20 @@ fn get_camera(s: &mut Session, params: &Value) -> Result { } fn fit_camera(s: &mut Session, params: &Value) -> Result { - if !params.as_object().is_some_and(|p| p.is_empty()) { - return Err(bad("render.camera.fit", "expected an empty parameter object")); - } + let aspect = match params.as_object() { + Some(p) if p.is_empty() => 1.0, + Some(p) if p.len() == 1 && p.contains_key("aspect") => match p["aspect"].as_f64() { + // The range a preview frame can show (`frame_rect` in the Render workspace). + Some(a) if (1e-6..=1e6).contains(&a) => a, + _ => return Err(bad("render.camera.fit", "aspect must be a number from 0.000001 to 1000000: the picture's width over its height")), + }, + _ => return Err(bad("render.camera.fit", "expected an empty parameter object or {aspect}")), + }; let scene = crate::product_scene::extract(s)?; let mut camera = s.camera.unwrap_or_default(); - if !scene.bounds.is_empty() { - camera.fit(&scene.bounds); + // Publish only a valid fitted camera; the previous one stays otherwise. + if !camera.fit_aspect(&scene.bounds, aspect) && !scene.bounds.is_empty() { + return Err(bad("render.camera.fit", "the product cannot be framed at this aspect: the fitted camera is out of range")); } s.camera = Some(camera); Ok(json!({"camera":camera,"empty":scene.bounds.is_empty(),"document_changed":false})) diff --git a/crates/engine/src/cmd/render/job_tests.rs b/crates/engine/src/cmd/render/job_tests.rs index cb1b4620f9..02ee106a08 100644 --- a/crates/engine/src/cmd/render/job_tests.rs +++ b/crates/engine/src/cmd/render/job_tests.rs @@ -1183,9 +1183,26 @@ fn registered_product_camera_fit_changes_only_view_and_keeps_orientation() { assert_eq!(fitted["document_changed"], false); let after = s.camera.unwrap(); assert_eq!((after.yaw, after.pitch, after.roll), (camera.yaw, camera.pitch, camera.roll)); + // A picture taller than wide moves the camera back by the ratio, keeping the orientation + // and the target; a wide one is the plain fit. + let tall = s.execute("render.camera.fit", &json!({"aspect":0.5})).unwrap(); + assert_eq!(tall["document_changed"], false); + let back = s.camera.unwrap(); + assert!((back.distance - 2.0 * after.distance).abs() < 1e-9, "{} vs {}", back.distance, after.distance); + assert_eq!((back.yaw, back.pitch, back.roll, back.target), (after.yaw, after.pitch, after.roll, after.target)); + s.execute("render.camera.fit", &json!({"aspect":2.0})).unwrap(); + assert_eq!(s.camera.unwrap(), after); assert_eq!(s.doc.to_json(), doc); assert_eq!(s.undo.len(), undo); + // Hostile aspects are refused and leave the finite camera, the document and undo alone: + // a subnormal one would otherwise divide the fitted distance into infinity. + for aspect in [json!(0), json!(-1), json!(1e-320), json!(1e-7), json!(1e7), json!("tall")] { + assert!(s.execute("render.camera.fit", &json!({"aspect":aspect})).is_err(), "aspect {aspect}"); + assert_eq!(s.camera.unwrap(), after); + } assert!(s.execute("render.camera.fit", &json!({"scale":0})).is_err()); + assert_eq!(s.doc.to_json(), doc); + assert_eq!(s.undo.len(), undo); } #[test] diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index 42258fe9ac..80cc6d62da 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -77,6 +77,7 @@ fn section(s: &mut Session, p: &Value) -> Result { s.section = Some((origin, normal)); s.section_hatch = bool_(p, "hatch").unwrap_or(true); 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 { diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index e2c5342d0b..5668c71440 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -956,6 +956,20 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result { // --------------------------------------------------------------------------------------------- // Drawing +/// The id a drawing command gives its geometry, checked against the names that geometry +/// will carry: a curve's id goes into the name of the faces the curve makes +/// (`F2:side:`), and a name reads some characters as its own. An id holding one of +/// them would split every name it lands in, so it is refused here (#5858). +fn named_id(cmd: &str, id: Option<&str>) -> Result> { + match id { + Some(i) if !solidcraft_doc::persistent_name::text_fits_name(i) => { + Err(bad(cmd, format!("the id `{i}` cannot be written into a name whole; give an id without `|`, `,`, `~`, `#` or `]`"))) + } + Some(i) => Ok(Some(i.to_string())), + None => Ok(None), + } +} + fn draw_line(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.line"; let p = &super::sketch_project::snap_points(s, p, cmd)?; @@ -966,6 +980,9 @@ fn draw_line(s: &mut Session, p: &Value) -> Result { let closed = bool_(p, "closed").unwrap_or(false); let infer = bool_(p, "infer").unwrap_or(false); let ids = string_list(cmd, p, "ids")?; + for id in &ids { + named_id(cmd, Some(id))?; + } let ((curves, cons), info) = draw(s, p, cmd, |sk, doc| { let args: Vec = list.iter().map(|v| parg(sk, doc, v, cmd)).collect::>()?; let n = args.len(); @@ -1308,7 +1325,7 @@ 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 id = str_(p, "id").map(str::to_string); + 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)?; // The origin is the sketch's frame, not a hold on the geometry: a circle centred there @@ -1375,7 +1392,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result { fn arc_three(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.arc.three_point"; let p = &super::sketch_project::snap_points(s, p, cmd)?; - let id = str_(p, "id").map(str::to_string); + let id = named_id(cmd, str_(p, "id"))?; let (out, info) = draw(s, p, cmd, |sk, doc| { let (a, b) = (req_parg(sk, doc, p, "start", cmd)?, req_parg(sk, doc, p, "end", cmd)?); let t = req_parg(sk, doc, p, "through", cmd)?; @@ -1402,7 +1419,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result { fn arc_center(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.arc.center_point"; let p = &super::sketch_project::snap_points(s, p, cmd)?; - let id = str_(p, "id").map(str::to_string); + let id = named_id(cmd, str_(p, "id"))?; let sweep = num(p, "sweep"); let (out, info) = draw(s, p, cmd, |sk, doc| { let c = req_parg(sk, doc, p, "center", cmd)?; @@ -1597,7 +1614,7 @@ fn slot_overall(s: &mut Session, p: &Value) -> Result { fn draw_point(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.point"; - let id = str_(p, "id").map(str::to_string); + let id = named_id(cmd, str_(p, "id"))?; // A point snapped while drawing (`"mid:"`, `"on::x,y"`, a model vertex or edge): // the snap makes it, held by its constraint, as it does for a line's end. Taken as bare // coordinates it was free to move off the midpoint it was put on (#4691). diff --git a/crates/engine/src/cmd/sketch_construction.rs b/crates/engine/src/cmd/sketch_construction.rs new file mode 100644 index 0000000000..bac2317242 --- /dev/null +++ b/crates/engine/src/cmd/sketch_construction.rs @@ -0,0 +1,349 @@ +//! Fast infinite reference lines. Preview and creation use the same analytic solutions. + +use serde_json::{Value, json}; +use solidcraft_geom::{Seg2, Vec2}; +use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch}; + +use super::sketch::{add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result}; +use super::{CommandSpec, in_sketch}; +use crate::params::{bad, expr, num, str_, vec2}; +use crate::schema::sketch_create::{ConstructionLineParams, ConstructionLineResult}; +use crate::schema::{TypedContract, output_schema_for}; +use crate::{Result, Session}; + +const CONTRACT: TypedContract = TypedContract::new(); +const CMD: &str = "sketch.construction_line"; + +macro_rules! variant { + ($id:literal, $label:literal, $mode:literal) => { + CommandSpec::new($id, $label, |s, p| { + crate::schema::run_checked_as(s, p, $id, |s, p| run(s, p, $mode)) + }) + .at("SKETCH", "CREATE") + .enabled(in_sketch) + .contract(&CONTRACT) + .output(output_schema_for::) + .params(concat!("Infinite construction line, ", $mode, ". at: point; through?: second point; curve?, curve2?: sketch references; angle?: degrees from sketch X; near?: solution selector; solution?: zero-based solution index. Tangents take circles/arcs, or an open spline/conic endpoint for tangent_at.", created_doc!())) + }; +} + +pub static COMMANDS: &[CommandSpec] = &[ + variant!("sketch.construction_line", "Construction Line", "free").icon("construction_line"), + variant!("sketch.construction_line.horizontal", "Horizontal Construction Line", "horizontal").icon("construction_line_horizontal"), + variant!("sketch.construction_line.vertical", "Vertical Construction Line", "vertical").icon("construction_line_vertical"), + variant!("sketch.construction_line.angle", "Construction Line at Angle", "angle").icon("construction_line_angle"), + variant!("sketch.construction_line.parallel", "Parallel Construction Line", "parallel").icon("construction_line_parallel"), + variant!("sketch.construction_line.perpendicular", "Perpendicular Construction Line", "perpendicular").icon("construction_line_perpendicular"), + variant!("sketch.construction_line.tangent_external", "Tangent through External Point", "tangent_external") + .icon("construction_line_tangent_external"), + variant!("sketch.construction_line.tangent_at", "Tangent at Curve Point", "tangent_at").icon("construction_line_tangent_at"), + variant!("sketch.construction_line.tangent_two", "Common Tangent", "tangent_two").icon("construction_line_tangent_two"), + variant!("sketch.construction_line.bisector", "Angle Bisector", "bisector").icon("construction_line_bisector"), +]; + +/// One mathematical solution, before adding any sketch entities. +#[derive(Clone, Copy, Debug)] +pub struct ConstructionLine { + pub start: Vec2, + pub direction: Vec2, +} + +impl ConstructionLine { + fn distance(self, p: Vec2) -> f64 { + (p - self.start).cross(self.direction).abs() + } +} + +/// The method belonging to an interactive command. The generic command defaults to two points. +pub fn construction_line_mode(id: &str) -> Option<&str> { + if id == CMD { Some("free") } else { id.strip_prefix("sketch.construction_line.") } +} + +fn point(sk: &Sketch, v: Option<&Value>, key: &str) -> Result { + v.and_then(|v| vec2(v).or_else(|| v.as_str().and_then(|id| sk.resolve_point(id)).and_then(|i| sk.point(i)))) + .ok_or_else(|| bad(CMD, format!("`{key}` must be a finite point or sketch point reference"))) +} + +fn reference(sk: &Sketch, p: &Value, key: &str) -> Result { + curve_ref(sk, p.get(key), CMD, key) +} + +fn line(sk: &Sketch, p: &Value, key: &str) -> Result<(Vec2, Vec2)> { + match str_(p, key) { + Some("x") if sk.curve_index("x").is_none() => Ok((Vec2::ZERO, Vec2::X)), + Some("y") if sk.curve_index("y").is_none() => Ok((Vec2::ZERO, Vec2::Y)), + _ => line_pts(sk, reference(sk, p, key)?).ok_or_else(|| bad(CMD, "the reference must be a straight line")), + } +} + +fn round(sk: &Sketch, i: usize) -> Result<(Vec2, f64)> { + let c = sk.center(i).ok_or_else(|| bad(CMD, "the reference must be a circle or circular arc"))?; + let r = sk + .radius(i) + .filter(|r| r.is_finite() && *r > 1e-9) + .ok_or_else(|| bad(CMD, "the reference must be a non-degenerate circle or circular arc"))?; + Ok((c, r)) +} + +/// A circular arc accepts tangency on its drawn sweep, including either end. +fn on_sweep(sk: &Sketch, i: usize, q: Vec2) -> bool { + if matches!(sk.curves.get(i).map(|c| &c.kind), Some(CurveKind::Circle { .. })) { + return true; + } + sk.segs(i).iter().any(|s| match s { + Seg2::Arc { center, start, sweep, .. } => { + let theta = (q - *center).angle(); + let span = if *sweep >= 0.0 { theta - start } else { start - theta }; + span.rem_euclid(std::f64::consts::TAU) <= sweep.abs() + 1e-8 || span.rem_euclid(std::f64::consts::TAU) > std::f64::consts::TAU - 1e-8 + } + _ => false, + }) +} + +fn endpoint_tangent(sk: &Sketch, i: usize, at: Vec2) -> Option<(usize, Vec2)> { + let ends = sk.curves.get(i)?.kind.ends()?; + [ends.0, ends.1].into_iter().find_map(|pi| { + let q = sk.point(pi)?; + (q.dist(at) <= 1e-6).then(|| super::sketch_create::leaving_dir(sk, i, pi).map(|d| (pi, d))).flatten() + }) +} + +/// All valid solutions in deterministic order. This reads the sketch and never changes it. +pub fn construction_line_solutions(sk: &Sketch, p: &Value, mode: &str) -> Result> { + let at = point(sk, p.get("at"), "at")?; + let mut out = Vec::new(); + let mut push = |start: Vec2, d: Vec2| { + if let Some(direction) = d.normalized() + && start.is_finite() + && direction.is_finite() + { + out.push(ConstructionLine { start, direction }); + } + }; + match mode { + "free" => push(at, point(sk, p.get("through"), "through")? - at), + "horizontal" => push(at, Vec2::X), + "vertical" => push(at, Vec2::Y), + "angle" => { + let a = num(p, "angle").ok_or_else(|| bad(CMD, "`angle` must be finite degrees"))?; + push(at, Vec2::from_angle(a.rem_euclid(360.0).to_radians())); + } + "parallel" | "perpendicular" => { + let (a, b) = line(sk, p, "curve")?; + push(at, if mode == "parallel" { b - a } else { (b - a).perp() }); + } + "bisector" => { + let (a, b) = line(sk, p, "curve")?; + let (c, d) = line(sk, p, "curve2")?; + let u = (b - a).normalized().ok_or_else(|| bad(CMD, "the reference has no direction"))?; + let v = (d - c).normalized().ok_or_else(|| bad(CMD, "the reference has no direction"))?; + let cross = u.cross(v); + if cross.abs() < 1e-9 { + return Err(bad(CMD, "the two lines must intersect")); + } + let origin = a + u * ((c - a).cross(v) / cross); + push(origin, u + v); + push(origin, u - v); + } + "tangent_at" => { + let i = reference(sk, p, "curve")?; + if let Ok((c, r)) = round(sk, i) { + let radial = (at - c).normalized().ok_or_else(|| bad(CMD, "the point must differ from the centre"))?; + let q = c + radial * r; + if on_sweep(sk, i, q) { + push(q, radial.perp()); + } + } else if let Some((pi, d)) = endpoint_tangent(sk, i, at) { + push(sk.point(pi).unwrap_or(at), d); + } else { + return Err(bad(CMD, "pick a point on a circle/arc, or an endpoint of an open spline or conic")); + } + } + "tangent_external" => { + let i = reference(sk, p, "curve")?; + let (c, r) = round(sk, i)?; + let delta = at - c; + let distance = delta.len(); + if distance <= r + 1e-9 { + return Err(bad(CMD, "the point must be outside the circle or arc")); + } + let u = delta / distance; + let ratio = r / distance; + for side in [1.0, -1.0] { + let contact = c + (u * ratio + u.perp() * (side * (1.0 - ratio * ratio).max(0.0).sqrt())) * r; + if on_sweep(sk, i, contact) { + push(at, contact - at); + } + } + } + "tangent_two" => { + let a = reference(sk, p, "curve")?; + let b = reference(sk, p, "curve2")?; + if a == b { + return Err(bad(CMD, "choose two different curves")); + } + let (c1, r1) = round(sk, a)?; + let (c2, r2) = round(sk, b)?; + let delta = c2 - c1; + let distance = delta.len(); + let u = delta.normalized().ok_or_else(|| bad(CMD, "concentric curves have no unique common tangent"))?; + for sign in [1.0, -1.0] { + let along = (r1 - sign * r2) / distance; + if along.abs() > 1.0 + 1e-12 { + continue; + } + let along = along.clamp(-1.0, 1.0); + for side in [1.0, -1.0] { + let n = u * along + u.perp() * (side * (1.0 - along * along).max(0.0).sqrt()); + let q1 = c1 + n * r1; + let q2 = c2 + n * (sign * r2); + if on_sweep(sk, a, q1) && on_sweep(sk, b, q2) { + push(q1, n.perp()); + } + } + } + } + _ => return Err(bad(CMD, "unknown construction method")), + } + out.dedup_by(|a, b| a.direction.cross(b.direction).abs() < 1e-9 && a.distance(b.start) < 1e-8); + if out.is_empty() { + return Err(bad(CMD, "no construction line satisfies these inputs")); + } + if out.iter().any(|l| l.start.x.abs() >= 1e9 || l.start.y.abs() >= 1e9) { + return Err(bad(CMD, "the construction is outside the supported coordinate range")); + } + Ok(out) +} + +/// Which preview solution a click selects. Equal distances keep the deterministic first one. +pub fn construction_line_choice(lines: &[ConstructionLine], p: &Value) -> Result { + if let Some(v) = p.get("solution") { + let i = v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < lines.len()); + return i.ok_or_else(|| bad(CMD, "`solution` is outside the available solutions")); + } + if let Some(near) = p.get("near") { + let near = vec2(near).ok_or_else(|| bad(CMD, "`near` must be a finite point"))?; + return lines + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| a.distance(near).total_cmp(&b.distance(near))) + .map(|(i, _)| i) + .ok_or_else(|| bad(CMD, "there are no solutions")); + } + if p.get("alternate").is_some_and(|v| !v.is_boolean()) { + return Err(bad(CMD, "`alternate` must be a boolean")); + } + Ok(usize::from(p.get("alternate").and_then(Value::as_bool).unwrap_or(false)).min(lines.len().saturating_sub(1))) +} + +fn run(s: &mut Session, p: &Value, default_mode: &str) -> Result { + let mode = str_(p, "mode").unwrap_or(default_mode); + if default_mode != "free" && mode != default_mode { + return Err(bad(CMD, "`mode` does not match this construction command")); + } + let p = super::sketch_project::snap_points(s, p, CMD)?; + let (out, info) = edit(s, &p, CMD, true, |sk, doc| { + let first_new = sk.points.len(); + let at = req_parg(sk, doc, &p, "at", CMD)?; + let through = if mode == "free" { Some(req_parg(sk, doc, &p, "through", CMD)?) } else { None }; + let mut params = p.clone(); + params["at"] = json!([at.pos().x, at.pos().y]); + let angle_expression = if mode == "angle" { + let e = expr(&p, "angle").ok_or_else(|| bad(CMD, "`angle` must be an angle or expression"))?; + let e = doc.with_design_unit(e.as_str(), solidcraft_doc::expr::Kind::Angle); + let radians = doc.eval(&e, solidcraft_doc::expr::Kind::Angle).map_err(|e| bad(CMD, format!("angle: {e}")))?; + if !radians.is_finite() || radians.abs() > 1e9 { + return Err(bad(CMD, "angle is outside the supported range")); + } + params["angle"] = json!(radians.to_degrees()); + Some(e) + } else { + None + }; + if let Some(q) = &through { + params["through"] = json!([q.pos().x, q.pos().y]); + } + let reference = if matches!(mode, "parallel" | "perpendicular" | "bisector") { + Some(line_or_axis_ref(sk, p.get("curve"), CMD, "curve")?) + } else if mode.starts_with("tangent_") { + Some(curve_ref(sk, p.get("curve"), CMD, "curve")?) + } else { + None + }; + let second = if mode == "bisector" { + Some(line_or_axis_ref(sk, p.get("curve2"), CMD, "curve2")?) + } else if mode == "tangent_two" { + Some(curve_ref(sk, p.get("curve2"), CMD, "curve2")?) + } else { + None + }; + let solutions = construction_line_solutions(sk, ¶ms, mode)?; + let choice = construction_line_choice(&solutions, ¶ms)?; + let chosen = solutions.get(choice).ok_or_else(|| bad(CMD, "no solution"))?; + let start_ref = if mode == "tangent_at" { + if let Some(i) = at.idx() { + if at.pos().dist(chosen.start) > 1e-6 { + return Err(bad(CMD, "the referenced point must lie on the curve")); + } + Some(i) + } else { + reference.and_then(|i| endpoint_tangent(sk, i, chosen.start).map(|(pi, _)| pi)) + } + } else if matches!(mode, "tangent_two" | "bisector") { + None + } else { + at.idx() + }; + let end = through.as_ref().map_or(chosen.start + chosen.direction * 10.0, |q| q.pos()); + let l = sk.add_line(chosen.start, end, start_ref, through.as_ref().and_then(|q| q.idx()), None)?; + if let Some(c) = sk.curves.get_mut(l) { + c.infinite = true; + c.construction = true; + } + let mut cons = Vec::new(); + let kind = match (mode, reference) { + ("horizontal", _) => Some(ConstraintKind::Horizontal { l }), + ("vertical", _) => Some(ConstraintKind::Vertical { l }), + ("angle", _) => Some(ConstraintKind::LineAngle { l, value: chosen.direction.angle() }), + ("parallel", Some(b)) => Some(ConstraintKind::Parallel { a: l, b }), + ("perpendicular", Some(b)) => Some(ConstraintKind::Perpendicular { a: l, b }), + ("tangent_at" | "tangent_external" | "tangent_two", Some(b)) => Some(ConstraintKind::Tangent { a: l, b }), + _ => None, + }; + if let Some(k) = kind { + let parameter = angle_expression.map(|e| doc.new_model_param(&e, "deg")); + cons.push(sk.add_constraint(k, parameter)?); + } + if let ("tangent_two", Some(b)) = (mode, second) { + cons.push(add_c(sk, ConstraintKind::Tangent { a: l, b })?); + } + let start = sk.curves.get(l).and_then(|c| c.kind.ends()).map(|(a, _)| a).ok_or_else(|| bad(CMD, "no line start"))?; + if let ("tangent_at", Some(c)) = (mode, reference) { + let already_on = + sk.constraints.iter().any(|k| matches!(k.kind, ConstraintKind::PointOnCurve { p, c: curve } if p == start && curve == c)); + let own_end = sk.curves.get(c).and_then(|c| c.kind.ends()).is_some_and(|(a, b)| a == start || b == start); + if sk.radius(c).is_some() && !already_on && !own_end { + cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: start, c })?); + } + } + if let ("bisector", Some(a), Some(b)) = (mode, reference, second) { + let (a0, a1) = sk.curves.get(a).and_then(|c| c.kind.ends()).ok_or_else(|| bad(CMD, "the reference must be a line"))?; + let pa = if sk.point(a1).is_some_and(|q| q.dist(chosen.start) > 1e-6) { a1 } else { a0 }; + let source = sk.point(pa).ok_or_else(|| bad(CMD, "missing reference point"))?; + let relative = source - chosen.start; + let mirror = chosen.start + chosen.direction * (2.0 * relative.dot(chosen.direction)) - relative; + let pb = sk.add_point(mirror, None)?; + for k in [ + ConstraintKind::PointOnCurve { p: start, c: a }, + ConstraintKind::PointOnCurve { p: start, c: b }, + ConstraintKind::PointOnCurve { p: pb, c: b }, + ConstraintKind::Symmetric { p: pa, q: pb, l }, + ] { + cons.push(add_c(sk, k)?); + } + } + super::sketch_create::solve_new_only(sk, first_new); + Ok((ids_of(sk, &[l]), cons)) + })?; + Ok(result(out, info)) +} diff --git a/crates/engine/src/cmd/sketch_corner_tests.rs b/crates/engine/src/cmd/sketch_corner_tests.rs index 60294befb4..f49402bd5a 100644 --- a/crates/engine/src/cmd/sketch_corner_tests.rs +++ b/crates/engine/src/cmd/sketch_corner_tests.rs @@ -74,9 +74,10 @@ fn fillet_and_chamfer_corners() { let e = s.execute("sketch.chamfer.equal_distance", &json!({"a": c[0], "b": c[1], "distance": 25})).unwrap_err().to_string(); assert!(e.contains("too large") || e.contains("does not fit") || e.contains("longer"), "{e}"); assert_eq!(sk(&s).curves.len(), n); - // A line meeting an arc, and two arcs (a slot's end rounded into a line). + // A line meeting an arc at a corner (down onto its top), and two arcs (a slot's end rounded + // into a line). let mut s = new_sketch(); - let l = curves(&run(&mut s, "sketch.line", json!({"points": [[-20, 10], [0, 10]]})))[0].clone(); + let l = curves(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [0, 10]]})))[0].clone(); let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{l}.end"), "end": [10, 0], "through": [7.0710678, 7.0710678]}))) [0] .clone(); @@ -117,6 +118,38 @@ fn a_chamfer_where_two_lines_lie_in_line_is_refused() { } } +/// A round that would use up a whole arc is refused, as one too large for two lines is: a +/// quarter arc of radius 5 running into a line along it, rounded with radius 5, was left as an +/// arc of no length (found by a local trim fuzzer). +#[test] +fn a_fillet_that_would_use_up_an_arc_is_refused() { + let mut s = new_sketch(); + // Centre (15, 5): from (10, 5) down to (15, 0), where the line along y = 0 leaves it. + let through = [15.0 - 2.5 * 2f64.sqrt(), 5.0 - 2.5 * 2f64.sqrt()]; + let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": [10, 5], "end": [15, 0], "through": through})))[0].clone(); + let l = curves(&run(&mut s, "sketch.line", json!({"points": [format!("{a}.end"), [-35, 0]], "infer": false})))[0].clone(); + let n = sk(&s).curves.len(); + let e = s.execute("sketch.fillet", &json!({"a": a, "b": l, "radius": 5})).unwrap_err().to_string(); + assert!(e.contains("does not fit"), "{e}"); + assert_eq!(sk(&s).curves.len(), n); +} + +/// A round where a line runs on along an arc's end, off only by rounding, is refused: there is no +/// corner, and the round came out as an arc about 1e-7 mm long (found by a local trim fuzzer, +/// after earlier rounds left the line a hair off the arc's tangent). +#[test] +fn a_fillet_where_a_line_runs_on_smoothly_from_an_arc_is_refused() { + let mut s = new_sketch(); + let h = 0.5 * 2f64.sqrt(); + let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": [1, 0], "end": [0, 1], "through": [h, h]})))[0].clone(); + // Off the arc's tangent at (0, 1) by 1e-7 rad. + let l = curves(&run(&mut s, "sketch.line", json!({"points": [format!("{a}.end"), [-10, 1.000001]], "infer": false})))[0].clone(); + let n = sk(&s).curves.len(); + let e = s.execute("sketch.fillet", &json!({"a": a, "b": l, "radius": 5})).unwrap_err().to_string(); + assert!(e.contains("smoothly") || e.contains("does not fit"), "{e}"); + assert_eq!(sk(&s).curves.len(), n); +} + /// The ids of the constraints driven by the model parameter `name`. fn driven_by(s: &Session, name: &str) -> Vec { sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect() diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 75597ae86c..39758b08f5 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -116,7 +116,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"), CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!( - "text: string (\\n breaks lines), or text_expression: the words built from text parameters (parameters.add with unit text) and quoted texts joined by + (\"Label + '-L'\"): the text keeps it, its words follow the parameters (a parameter edit or a configuration row relabels it) and `exprs` returns it as `text`; at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \ + "text: string (\\n breaks lines), or text_expression: the words built from text parameters (parameters.add with unit text), number parameters and quoted texts joined by + (\"Label + '-L'\", \"'W' + Width\"; a number parameter shows its value in the design's units, \"85 mm\", an angle in degrees, \"30°\"; number(Width) is the number alone and number(Width, 2) has 2 decimals): the text keeps it, its words follow the parameters (a parameter edit or a configuration row relabels it) and `exprs` returns it as `text`; at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \ font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ", text_style_doc!(), created_doc!() @@ -222,7 +222,7 @@ fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option t.clone() } (None, Some(Value::String(e))) => { - let words = doc.eval_words(e.trim()).map_err(|error| bad(cmd, format!("`text_expression`: {error}")))?; + let words = doc.eval_words(e.trim(), &doc.param_values().0).map_err(|error| bad(cmd, format!("`text_expression`: {error}")))?; exprs.text = Some(e.trim().to_string()); words } @@ -772,7 +772,7 @@ fn midpoint_line(s: &mut Session, p: &Value) -> Result { } /// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end). -fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { +pub(super) fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { match sk.curves.get(ci)?.kind { CurveKind::Line { a, b } => { let (pa, pb) = (sk.point(a)?, sk.point(b)?); diff --git a/crates/engine/src/cmd/sketch_face.rs b/crates/engine/src/cmd/sketch_face.rs index d8fa720a4b..2742b8ada6 100644 --- a/crates/engine/src/cmd/sketch_face.rs +++ b/crates/engine/src/cmd/sketch_face.rs @@ -18,48 +18,68 @@ pub(in crate::cmd) fn capture_internal(s: &Session, value: &Value, cmd: &str) -> capture_impl(s, value, cmd, true) } -fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result { +/// A planar face pick `{face, fixed?, body?, index?, path?}` as [`capture`] reads it before it +/// looks at the design. +pub(crate) struct FacePickForm<'a> { + /// The picked point, in world coordinates. + pub point: solidcraft_geom::Vec3, + /// Keep the plane as it is now instead of following the face. + pub fixed: bool, + /// The exact pick: the world body and the face's index, given together. + pub body: Option<&'a str>, + pub index: Option, + /// The occurrence path of the body's placement. + pub path: Option>, +} + +/// What [`capture`] reads `value` as, or its message for the first key it refuses, in the order +/// `face`, `fixed`, `body`, `index`, the two together, then `path`. +pub(crate) fn face_pick_form(value: &Value) -> std::result::Result, &'static str> { let point = value .get("face") .and_then(vec3) .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) - .ok_or_else(|| bad(cmd, "face point must be three finite world coordinates"))?; + .ok_or("face point must be three finite world coordinates")?; let fixed = match value.get("fixed") { None => false, Some(Value::Bool(fixed)) => *fixed, - _ => return Err(bad(cmd, "fixed must be a boolean")), + _ => return Err("fixed must be a boolean"), }; - let state = s.model.state(); - let doc = s.placed_document(); - let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); - let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; - let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?; - let requested_body = match value.get("body") { + let body = match value.get("body") { None => None, Some(Value::String(body)) if !body.is_empty() => Some(body.as_str()), - _ => return Err(bad(cmd, "body must be the picked world body name")), + _ => return Err("body must be the picked world body name"), }; - let requested_index = match value.get("index") { + let index = match value.get("index") { None => None, - Some(value) => { - Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or_else(|| bad(cmd, "index must be a nonnegative face index"))?) - } + Some(value) => Some(value.as_u64().and_then(|index| usize::try_from(index).ok()).ok_or("index must be a nonnegative face index")?), }; - if requested_body.is_some() != requested_index.is_some() { - return Err(bad(cmd, "an exact face pick requires both body and index")); + if body.is_some() != index.is_some() { + return Err("an exact face pick requires both body and index"); } - let requested_path = match value.get("path") { + let path = match value.get("path") { None => None, Some(value) => Some( value .as_array() .filter(|path| path.len() <= 64) - .ok_or_else(|| bad(cmd, "path must contain at most64 occurrence ids"))? + .ok_or("path must contain at most64 occurrence ids")? .iter() - .map(|id| id.as_u64().filter(|id| *id != 0).ok_or_else(|| bad(cmd, "path ids must be nonzero integers"))) - .collect::>>()?, + .map(|id| id.as_u64().filter(|id| *id != 0).ok_or("path ids must be nonzero integers")) + .collect::, _>>()?, ), }; + Ok(FacePickForm { point, fixed, body, index, path }) +} + +fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result { + let FacePickForm { point, fixed, body: requested_body, index: requested_index, path: requested_path } = + face_pick_form(value).map_err(|why| bad(cmd, why))?; + let state = s.model.state(); + let doc = s.placed_document(); + let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component)); + let author_frame = EmbossFace::component_frame(&doc, s.active_component, &author_path)?; + let author_inverse = mat_inverse(&author_frame).ok_or_else(|| bad(cmd, "invalid sketch author frame"))?; let selected_path = requested_path .clone() .or_else(|| requested_body.and_then(|name| state.body(name)).map(|body| doc.first_path(doc.model_body_component(body)))); diff --git a/crates/engine/src/cmd/sketch_ids_tests.rs b/crates/engine/src/cmd/sketch_ids_tests.rs index cb3d61cb50..608eb1fe0f 100644 --- a/crates/engine/src/cmd/sketch_ids_tests.rs +++ b/crates/engine/src/cmd/sketch_ids_tests.rs @@ -353,3 +353,32 @@ fn conic_curves_take_the_n_prefix_not_the_constraints_k() { assert!(id.starts_with('n'), "a pattern copy keeps the conic prefix: {id}"); } } + +/// A curve id goes into the face names of the features that use the curve, so an id with a +/// character the name grammar reads (`|`, `,`, `~`, `]`, `#`) would split every name it +/// lands in: an id of `c|1` gave the edges of an extrusion the names +/// `F2:side:c|1#1|F2:side:c|1#2~1`, three separators where an edge has two faces and one +/// separator. An id that cannot be written into a name whole is refused instead (#5858). +#[test] +fn a_curve_id_that_cannot_be_written_into_a_name_is_refused() { + let mut s = new_sketch(); + for id in ["c|1", "c,1", "c~1", "c]1", "c#1"] { + let p = json!({"center": [0, 0], "radius": 5, "id": id}); + let e = match s.execute("sketch.circle.center", &p) { + Ok(v) => panic!("the id `{id}` was taken: {v:?}"), + Err(e) => e.to_string(), + }; + assert!(e.contains(id), "the error names the id: {e}"); + } + // The id that can be written into a name is still taken, and the faces it makes are + // named with it whole. + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "id": "c1"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10})); + let edges = run(&mut s, "model.edges", json!({"body": "Body1"})); + let names: Vec = edges["edges"].as_array().unwrap().iter().filter_map(|e| e["name"].as_str().map(str::to_string)).collect(); + assert!(!names.is_empty(), "{edges}"); + for name in &names { + assert_eq!(name.matches('|').count(), 1, "an edge names two faces and separates them once: {name}"); + } +} diff --git a/crates/engine/src/cmd/sketch_infinite_tests.rs b/crates/engine/src/cmd/sketch_infinite_tests.rs index eae8092ad0..85f30fb002 100644 --- a/crates/engine/src/cmd/sketch_infinite_tests.rs +++ b/crates/engine/src/cmd/sketch_infinite_tests.rs @@ -33,6 +33,201 @@ fn profiles(s: &Session) -> usize { s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.len() } +#[test] +fn construction_line_methods_make_infinite_lines_with_direction_constraints() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [3, 4]], "ids": ["reference"], "infer": false})); + for (mode, expected) in [ + ("horizontal", Vec2::new(1.0, 0.0)), + ("vertical", Vec2::new(0.0, 1.0)), + ("angle", Vec2::new(0.5, 3.0_f64.sqrt() / 2.0)), + ("parallel", Vec2::new(0.6, 0.8)), + ("perpendicular", Vec2::new(-0.8, 0.6)), + ] { + let out = run(&mut s, "sketch.construction_line", json!({"mode": mode, "at": [10, 10], "curve": "reference", "angle": 60})); + let sk = sketch(&s); + let id = out["curves"][0].as_str().unwrap(); + let c = curve(&sk, id); + assert!(c.infinite && c.construction); + let CurveKind::Line { a, b } = c.kind else { panic!("expected a line") }; + let direction = (sk.point(b).unwrap() - sk.point(a).unwrap()).normalized().unwrap(); + assert!(direction.cross(expected).abs() < 1e-6, "{mode}: {direction:?}"); + assert_eq!(out["constraints"].as_array().unwrap().len(), 1); + } + assert!(s.execute("sketch.construction_line", &json!({"mode": "parallel", "at": [0, 0], "curve": "missing"})).is_err()); +} + +#[test] +fn construction_angle_keeps_its_parameter_expression() { + let mut s = new_sketch(); + run(&mut s, "parameters.add", json!({"name":"inclination","expression":"30 deg"})); + let r = run(&mut s, "sketch.construction_line.angle", json!({"at":[5,5],"angle":"inclination"})); + let id = r["curves"][0].as_str().unwrap(); + let angle = |s: &Session| { + let sk = sketch(s); + let CurveKind::Line { a, b } = curve(&sk, id).kind else { panic!("line") }; + (sk.point(b).unwrap() - sk.point(a).unwrap()).angle().to_degrees() + }; + assert!((angle(&s) - 30.0).abs() < 1e-6); + run(&mut s, "parameters.change", json!({"name":"inclination","expression":"60 deg"})); + assert!((angle(&s) - 60.0).abs() < 1e-6); +} + +#[test] +fn construction_common_tangent_exposes_all_four_solutions() { + let mut s = new_sketch(); + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":5,"id":"a"})); + run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":3,"id":"b"})); + let p = json!({"at":[10,10],"curve":"a","curve2":"b"}); + let lines = super::super::sketch_construction::construction_line_solutions(&sketch(&s), &p, "tangent_two").unwrap(); + assert_eq!(lines.len(), 4); + for (i, expected) in lines.iter().enumerate() { + let mut p = p.clone(); + p["solution"] = json!(i); + let r = run(&mut s, "sketch.construction_line.tangent_two", p); + let sk = sketch(&s); + let CurveKind::Line { a, b } = curve(&sk, r["curves"][0].as_str().unwrap()).kind else { panic!("line") }; + let start = sk.point(a).unwrap(); + let direction = (sk.point(b).unwrap() - start).normalized().unwrap(); + assert!(direction.cross(expected.direction).abs() < 1e-6); + assert!((start - expected.start).cross(direction).abs() < 1e-6); + for id in ["a", "b"] { + let c = sk.curve_index(id).unwrap(); + assert!(((sk.center(c).unwrap() - start).cross(direction).abs() - sk.radius(c).unwrap()).abs() < 1e-6); + } + } +} + +#[test] +fn construction_tangent_at_an_open_curve_endpoint_reuses_the_point() { + for (command, params, expected) in [ + ("sketch.spline.control_point", json!({"points":[[0,0],[0,10],[20,10],[20,0]]}), Vec2::Y), + ("sketch.conic", json!({"start":[0,0],"end":[20,0],"apex":[10,20],"rho":0.5}), Vec2::new(1.0, 2.0)), + ] { + let mut s = new_sketch(); + let source = run(&mut s, command, params)["curves"][0].as_str().unwrap().to_string(); + let start = format!("{source}.start"); + let r = run(&mut s, "sketch.construction_line.tangent_at", json!({"curve":source,"at":start})); + let sk = sketch(&s); + let CurveKind::Line { a, b } = curve(&sk, r["curves"][0].as_str().unwrap()).kind else { panic!("line") }; + assert_eq!(a, sk.resolve_point(&start).unwrap()); + assert!(sk.point(a).unwrap().dist(Vec2::ZERO) < 1e-7); + let direction = (sk.point(b).unwrap() - sk.point(a).unwrap()).normalized().unwrap(); + assert!(direction.cross(expected.normalized().unwrap()).abs() < 1e-7, "{command}: {direction:?}"); + assert_eq!(r["constraints"].as_array().unwrap().len(), 1); + } +} + +#[test] +fn construction_line_tangents_touch_the_circle() { + let mut s = new_sketch(); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "id": "circle"})); + for (mode, at, alternate) in [("tangent_external", [13, 0], false), ("tangent_external", [13, 0], true), ("tangent_at", [5, 0], false)] { + let out = run(&mut s, "sketch.construction_line", json!({"mode": mode, "at": at, "curve": "circle", "alternate": alternate})); + let sk = sketch(&s); + let id = out["curves"][0].as_str().unwrap(); + let CurveKind::Line { a, b } = curve(&sk, id).kind else { panic!("expected a line") }; + let p = sk.point(a).unwrap(); + let d = (sk.point(b).unwrap() - p).normalized().unwrap(); + let ci = sk.curve_index("circle").unwrap(); + let center = sk.center(ci).unwrap(); + let radius = sk.radius(ci).unwrap(); + assert!(((center - p).cross(d).abs() - radius).abs() < 1e-5); + } + assert!(s.execute("sketch.construction_line", &json!({"mode":"tangent_external", "at":[0,0], "curve":"circle"})).is_err()); +} + +#[test] +fn construction_common_tangent_and_bisector() { + let mut s = new_sketch(); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "id": "c1"})); + run(&mut s, "sketch.circle.center", json!({"center": [20, 0], "radius": 3, "id": "c2"})); + let out = run(&mut s, "sketch.construction_line", json!({"mode":"tangent_two", "curve":"c1", "curve2":"c2", "at":[10, 8]})); + let sk = sketch(&s); + let CurveKind::Line { a, b } = curve(&sk, out["curves"][0].as_str().unwrap()).kind else { panic!("expected a line") }; + let start = sk.point(a).unwrap(); + let direction = (sk.point(b).unwrap() - start).normalized().unwrap(); + for id in ["c1", "c2"] { + let c = sk.curve_index(id).unwrap(); + assert!(((sk.center(c).unwrap() - start).cross(direction).abs() - sk.radius(c).unwrap()).abs() < 1e-5); + } + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]],"ids":["a"],"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[0,0],[0,10]],"ids":["b"],"infer":false,"connect":false})); + let out = run(&mut s, "sketch.construction_line", json!({"mode":"bisector", "curve":"a", "curve2":"b", "at":[5,5]})); + let sk = sketch(&s); + let CurveKind::Line { a, b } = curve(&sk, out["curves"][0].as_str().unwrap()).kind else { panic!("expected a line") }; + let direction = (sk.point(b).unwrap() - sk.point(a).unwrap()).normalized().unwrap(); + assert!((direction.x.abs() - direction.y.abs()).abs() < 1e-5); +} + +#[test] +fn construction_tools_keep_profiles_and_undo_and_save_as_one_edit() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})); + let before = s.doc.to_json(); + let depth = s.undo.len(); + let r = run(&mut s, "sketch.construction_line", json!({"at":[-5,5],"through":[30,5]})); + assert_eq!(s.undo.len(), depth + 1); + assert_eq!(profiles(&s), 1); + let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let id = r["curves"][0].as_str().unwrap(); + let c = curve(doc.sketch(s.active_sketch.unwrap()).unwrap(), id); + assert!(c.construction && c.infinite); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn construction_tangents_respect_arc_sweeps_and_invalid_inputs_are_atomic() { + let mut s = new_sketch(); + run(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[5,0],"end":[0,5],"id":"arc"})); + let sk = sketch(&s); + let p = json!({"at":[13,0],"curve":"arc"}); + let lines = super::super::sketch_construction::construction_line_solutions(&sk, &p, "tangent_external").unwrap(); + assert_eq!(lines.len(), 1, "the lower-circle tangent is outside the quarter arc"); + assert!(lines[0].direction.y > 0.0); + let r = run(&mut s, "sketch.construction_line.tangent_external", p); + let sk = sketch(&s); + let CurveKind::Line { a, b } = curve(&sk, r["curves"][0].as_str().unwrap()).kind else { panic!("line") }; + let actual = (sk.point(b).unwrap() - sk.point(a).unwrap()).normalized().unwrap(); + assert!(actual.cross(lines[0].direction).abs() < 1e-7); + for p in [ + json!({}), + json!({"at":[0,0],"curve":"arc","mode":"tangent_external"}), + json!({"at":[10,10],"mode":"angle","angle":"bad"}), + json!({"at":[10,10],"mode":"horizontal","solution":99}), + json!({"at":[10,10],"mode":"horizontal","alternate":"false"}), + json!({"at":[1,1],"through":[1,1]}), + ] { + let before = s.doc.to_json(); + assert!(s.execute("sketch.construction_line", &p).is_err(), "{p}"); + assert_eq!(s.doc.to_json(), before, "{p}"); + } +} + +#[test] +fn construction_relations_follow_source_edits_without_an_auxiliary_dimension() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,0],[20,0]],"ids":["a"],"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[0,0],[0,20]],"ids":["b"],"infer":false})); + let parallel = run(&mut s, "sketch.construction_line.parallel", json!({"curve":"a","at":[5,5]})); + let bisector = run(&mut s, "sketch.construction_line.bisector", json!({"curve":"a","curve2":"b","at":[10,10]})); + assert!(sketch(&s).constraints.iter().all(|c| !matches!(c.kind, solidcraft_sketch::ConstraintKind::Distance { .. }))); + run(&mut s, "sketch.drag", json!({"entity":"a.end","from":[20,0],"to":[20,10]})); + let sk = sketch(&s); + let direction = |id: &str| { + let CurveKind::Line { a, b } = curve(&sk, id).kind else { panic!("line") }; + (sk.point(b).unwrap() - sk.point(a).unwrap()).normalized().unwrap() + }; + let a = direction("a"); + let b = direction("b"); + let p = direction(parallel["curves"][0].as_str().unwrap()); + let d = direction(bisector["curves"][0].as_str().unwrap()); + assert!(a.cross(p).abs() < 1e-5); + assert!((a.dot(d).abs() - b.dot(d).abs()).abs() < 1e-5); +} + #[test] fn the_line_type_switches_on_and_off_and_stays_out_of_profiles() { let mut s = new_sketch(); diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 290e32fca5..29b4ac42c0 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -121,7 +121,13 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result { fn curve_by_id(sk: &Sketch, id: &str, cmd: &str) -> Result { let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; if sk.is_linked_curve(c) { - return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first"))); + let action = match cmd { + "sketch.trim" => "Trim", + "sketch.extend" => "Extend", + "sketch.break" => "Break", + _ => "edit", + }; + return Err(bad(cmd, format!("`{id}` is projected from the model — use Break Link on it, then {action} again"))); } if sk.freeform_offsets.iter().any(|offset| offset.output == id) || sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.roles.iter().any(|role| role.output == id)) @@ -1749,12 +1755,29 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result| end.is_some_and(|e| t.dist(e) < TOL * 10.0); + // Where the curves meet smoothly (a line along an arc's end, off by rounding), the round's + // tangent points fall on the corner within rounding: an arc of no length, refused as such. + let (mut smooth, mut short) = (false, false); let mut best: Option<(f64, Vec2, Vec2, Vec2)> = None; for oa in offsets(&sa) { for ob in offsets(&sb) { for cen in intersections(&oa, &ob) { let (ta, tb) = (sa.project(cen), sb.project(cen)); - if on_bounded(&sa, ta).is_none() || on_bounded(&sb, tb).is_none() || ta.dist(pc) < 1e-9 || tb.dist(pc) < 1e-9 { + if on_bounded(&sa, ta).is_none() || on_bounded(&sb, tb).is_none() { + continue; + } + if ta.dist(pc) < TOL * 10.0 || tb.dist(pc) < TOL * 10.0 { + smooth = true; + continue; + } + if used_up(ta, far_a) || used_up(tb, far_b) { + short = true; continue; } let score = ta.dist(pc) + tb.dist(pc); @@ -1764,7 +1787,9 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result"#; + let mut s = new_sketch(); + run(&mut s, "sketch.insert_svg", json!({"text": svg})); + let drawn: std::collections::BTreeSet = sketch(&s).curves.iter().map(|c| c.id.clone()).collect(); + assert_eq!(drawn.len(), 5, "three lines and two Bézier curves: {drawn:?}"); + let line = sketch(&s).curves[0].id.clone(); + let out = run(&mut s, "sketch.offset", json!({"curves": [line], "distance": 1, "side": [100, 100]})); + let made: std::collections::BTreeSet = ids(&out["curves"]).into_iter().collect(); + assert!(!made.is_empty() && made.is_disjoint(&drawn), "{out}"); + + let once = |ids: &[String], what: &str| { + let set: std::collections::BTreeSet<&String> = ids.iter().collect(); + assert_eq!(set.len(), ids.len(), "{what} names a curve twice: {ids:?}"); + }; + let si = run(&mut s, "sketch.inspect", json!({})); + let state = s.model.state(); + let solved = state.sketch(s.active_sketch.unwrap()).unwrap(); + let profiles = si["profiles"].as_array().unwrap(); + assert_eq!(profiles.len(), 2, "the outline and the wall around it: {si}"); + for (p, solved) in profiles.iter().zip(&solved.profiles) { + let curves = ids(&p["curves"]); + once(&curves, "a profile"); + // In the order the loop walks them, as the solved profile has them. + assert_eq!(curves, solidcraft_sketch::distinct_curves(&solved.outer_curves)); + let holes: Vec> = p["holes"].as_array().unwrap().iter().map(ids).collect(); + holes.iter().for_each(|h| once(h, "a hole")); + let set = |v: &[String]| v.iter().cloned().collect::>(); + match holes.as_slice() { + [] => assert_eq!(set(&curves), drawn, "the outline is the drawn curves"), + [hole] => { + assert_eq!(set(&curves), made, "the wall's outside is the offset"); + assert_eq!(set(hole), drawn, "its hole is the outline"); + } + _ => panic!("one hole at most: {p}"), + } + } + + // The lists are a profile pick as they stand (a pick refuses a curve named twice): the + // wall extrudes from them. + let wall = profiles.iter().find(|p| p["holes"].as_array().is_some_and(|h| !h.is_empty())).unwrap(); + let pick = json!({"type": "whole_boundaries", "profiles": [{"outer": wall["curves"], "holes": wall["holes"]}]}); + let areas = profiles.iter().map(|p| p["area_mm2"].as_f64().unwrap()).collect::>(); + let ring = areas.iter().copied().fold(f64::INFINITY, f64::min).min((areas[1] - areas[0]).abs()); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10, "profiles": pick})); + let volume = body_volumes(&mut s); + assert_eq!(volume.len(), 1); + assert!((volume[0] - 10.0 * ring).abs() < 0.02 * volume[0], "{volume:?} against {areas:?}"); +} + /// The arc's counter-clockwise sweep from its stored start to its end, degrees, from `sketch.inspect`. fn arc_sweep_deg(si: &Value, id: &str) -> f64 { let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap(); diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 6bbdbad5e7..d372a3e292 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -16,7 +16,7 @@ use crate::{EngineError, Result, Session}; macro_rules! refs_doc { () => { - "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]" + "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z|name} | {plane: XY|XZ|YZ|name}]" }; } @@ -110,7 +110,7 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result { return Ok(LinkSource::Body { body: b.to_string() }); } if let Some(a) = str_(v, "axis") { - return Ok(LinkSource::Axis { name: a.to_ascii_uppercase() }); + return axis_source(s, a, cmd); } if let Some(p) = str_(v, "plane") { return Ok(LinkSource::Plane { name: p.to_string() }); @@ -121,6 +121,17 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result { Err(bad(cmd, format!("cannot project {v}"))) } +/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324). +fn axis_source(s: &Session, name: &str, cmd: &str) -> Result { + if ["X", "Y", "Z"].iter().any(|axis| name.eq_ignore_ascii_case(axis)) { + return Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }); + } + match s.doc.find_feature(name) { + Some(f) if matches!(f.kind, FeatureKind::ConstructionAxis { .. }) => Ok(LinkSource::Axis { name: f.name.clone() }), + _ => Err(bad(cmd, format!("unknown axis `{name}`: X, Y, Z or the name of a construction axis"))), + } +} + /// The plane of a sketch feature, resolved against `st`: the model before the sketch as its /// component sees it, which is what evaluation resolves it against. A construction plane built /// from the model (through a sketch line, three points, between two faces…) exists only there. diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 7204ee9c4d..a17c1fa40d 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -216,6 +216,53 @@ fn project_edges_bodies_sketches_and_origin() { assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing"); } +/// A construction axis projects as an origin axis does, as its line in the sketch plane, and stays +/// linked to it: the projection follows when the axis moves and when it is renamed. The report's +/// steps (#9324): the axis made after the sketch, then moved before it. +#[test] +fn a_construction_axis_projects_and_stays_linked() { + let mut s = Session::default(); + let sketch = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let axis = |x: f64| json!({"a": {"point": [x, 0, 0]}, "b": {"point": [x, 30, 0]}, "name": "Axis1"}); + run(&mut s, "construct.axis.two_points", axis(10.0)); + run(&mut s, "timeline.reorder", json!({"feature": "Axis1", "position": 0})); + run(&mut s, "sketch.edit", json!({"sketch": sketch})); + let r = run(&mut s, "sketch.project", json!({"refs": [{"axis": "Axis1"}]})); + let line = r["links"][0]["curves"][0].clone(); + // Where the projected line runs: its two ends' x, and its length along y. + let at = |s: &mut Session| { + run(s, "sketch.edit", json!({"sketch": sketch})); + let si = inspect(s); + let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == line).cloned().unwrap(); + run(s, "sketch.finish", json!({})); + let n = |v: &Value| v.as_f64().unwrap(); + (n(&c["start_at"][0]), n(&c["end_at"][0]), (n(&c["end_at"][1]) - n(&c["start_at"][1])).abs()) + }; + run(&mut s, "sketch.finish", json!({})); + let (x0, x1, length) = at(&mut s); + assert!((x0 - 10.0).abs() < 1e-9 && (x1 - 10.0).abs() < 1e-9 && length > 30.0, "{x0} {x1} {length}"); + run(&mut s, "timeline.redefine", json!({"feature": "Axis1", "command": "construct.axis.two_points", "params": axis(25.0)})); + let (x0, x1, _) = at(&mut s); + assert!((x0 - 25.0).abs() < 1e-9 && (x1 - 25.0).abs() < 1e-9, "the projection follows the axis: {x0} {x1}"); + run(&mut s, "timeline.rename", json!({"feature": "Axis1", "name": "Hinge axis"})); + let state = s.model.state(); + let link = &state.sketch(sketch).unwrap().sketch.links[0]; + assert_eq!(link.source, solidcraft_sketch::LinkSource::Axis { name: "Hinge axis".into() }); + assert!(!link.lost, "renamed, the axis is still found"); + // A design with the axis after the sketch opens with the link lost, saying why. + run(&mut s, "timeline.reorder", json!({"feature": "Hinge axis", "position": 1})); + let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(opened.model.state().sketch(sketch).unwrap().sketch.links[0].lost); + let warnings = opened.model.result(sketch).and_then(|r| r.warning.clone()).unwrap_or_default(); + assert!(warnings.contains("it must come earlier in the timeline"), "{warnings}"); + run(&mut s, "timeline.reorder", json!({"feature": "Hinge axis", "position": 0})); + // An axis that is no origin axis and no construction axis is refused, by its name. + run(&mut s, "sketch.edit", json!({"sketch": sketch})); + let error = s.execute("sketch.project", &json!({"refs": [{"axis": "Axis9"}]})).unwrap_err().to_string(); + assert!(error.contains("unknown axis `Axis9`"), "{error}"); +} + #[test] fn project_whole_body_can_create_unlinked_geometry() { let mut s = Session::default(); @@ -259,6 +306,21 @@ fn break_link_makes_normal_geometry() { assert!(s.execute("sketch.break_link", &json!({"link": "j99"})).is_err()); } +/// Trim refuses projected edges until Break Link; the message names that command (#8979). +#[test] +fn trim_refuses_projected_edges_until_break_link() { + let mut s = Session::default(); + box_part(&mut s); + let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}})); + let link = r["projected"].as_str().unwrap().to_string(); + let curve = linked_curves(&inspect(&mut s))[0]["id"].as_str().unwrap().to_string(); + let err = s.execute("sketch.trim", &json!({"curve": curve, "at": [20, 15]})).unwrap_err().to_string(); + assert!(err.contains("Break Link"), "{err}"); + assert!(err.contains("projected"), "{err}"); + run(&mut s, "sketch.break_link", json!({"link": link})); + run(&mut s, "sketch.trim", json!({"curve": curve, "at": [20, 15]})); +} + /// The sketch's curves as evaluated (after its links were refreshed): (id, construction). fn evaluated_curves(s: &Session, id: u64) -> Vec<(String, bool)> { s.model.state().sketch(id).unwrap().sketch.curves.iter().map(|c| (c.id.clone(), c.construction)).collect() @@ -1083,3 +1145,56 @@ fn a_rebuilt_projection_keeps_user_constraints_without_a_warning() { assert!(warning.is_none(), "{warning:?}"); } } + +/// #9001: an angle (an L profile, wall thickness `d1`, legs `d2`) with a sketch on its face and +/// a hole dimensioned to the inner wall's projected edge. Making the wall thicker moves the +/// inner corner by more than a tenth of the face; the projection follows it (it was kept as it +/// was, "boundary identity is ambiguous"), and the hole with it, as it already did for `d2`. +#[test] +fn a_thicker_angle_moves_the_projected_face_of_the_sketch_on_it() { + let mut s = Session::default(); + run(&mut s, "parameters.change", json!({"name": "d1", "expression": "20 mm"})); + run(&mut s, "parameters.change", json!({"name": "d2", "expression": "80 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [80, 0], [80, 20], [20, 20], [20, 80], [0, 80]], "closed": true}))["curves"]); + for (i, line) in l.iter().enumerate() { + let mode = if i % 2 == 0 { "horizontal" } else { "vertical" }; + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": line, "mode": mode})); + } + run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", l[0]), "b": "origin"})); + for (k, value) in [(0, "d2"), (1, "d1"), (4, "d1"), (5, "d2")] { + run(&mut s, "sketch.dimension", json!({"entities": [l[k]], "value": value})); + } + assert_eq!(inspect(&mut s)["dof"], 0); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 30})); + let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 30]}}))["sketch"].as_u64().unwrap(); + let si = inspect(&mut s); + // The inner wall's edge, from (20, 20) to (20, 80) on the face. + let wall = linked_curves(&si) + .into_iter() + .find(|c| { + ["start_at", "end_at"].iter().all(|e| (c[e][0].as_f64().unwrap().abs() - 20.).abs() < 1e-6 && c[e][1].as_f64().unwrap().abs() > 19.) + }) + .unwrap_or_else(|| panic!("{si}")); + let hole = ids(&run(&mut s, "sketch.circle.center", json!({"center": [10, 50], "radius": 3}))["curves"])[0].clone(); + run(&mut s, "sketch.dimension", json!({"entities": [format!("{hole}.center"), wall["id"]], "value": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "parameters.change", json!({"name": "d1", "expression": "30 mm"})); + let check = |s: &Session| { + let warning = s.model.result(sketch).and_then(|r| r.warning.clone()); + assert!(warning.is_none(), "{warning:?}"); + let st = s.model.state(); + let ss = st.sketch(sketch).unwrap(); + assert!(ss.sketch.links.iter().all(|l| !l.lost)); + let world = |p: solidcraft_geom::Vec2| ss.plane.to_world(p); + let at = |x: f64, y: f64| ss.sketch.points.iter().any(|p| world(p.pos).dist(solidcraft_geom::Vec3::new(x, y, 30.)) < 1e-6); + // The inner corner is at (30, 30) now, and the hole 10 from the wall that moved. + assert!(at(30., 30.) && !at(20., 20.)); + let center = ss.sketch.points.iter().find(|p| p.id == format!("{hole}.center")).unwrap(); + assert!((world(center.pos).x - 20.).abs() < 1e-6, "{:?}", world(center.pos)); + assert_eq!(ss.sketch.constraints.len(), 1); + }; + check(&s); + check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); +} diff --git a/crates/engine/src/cmd/sketch_text_expr_tests.rs b/crates/engine/src/cmd/sketch_text_expr_tests.rs index 5a8749d65e..779a14a659 100644 --- a/crates/engine/src/cmd/sketch_text_expr_tests.rs +++ b/crates/engine/src/cmd/sketch_text_expr_tests.rs @@ -389,11 +389,55 @@ fn a_texts_words_follow_its_text_parameters_and_so_does_its_cut() { run(&mut s, "sketch.finish", json!({})); assert_eq!((words(&s, &link).as_str(), word_expr(&s, &link)), ("OK", Value::Null)); - // An expression naming no text parameter, or a number parameter, is refused. - run(&mut s, "parameters.add", json!({"name": "W", "expression": "10 mm"})); + // An expression naming an unknown parameter, or a text parameter in number(), is refused. run(&mut s, "sketch.edit", json!({"sketch": "S"})); - for (bad, says) in [("Nope + 'x'", "Nope"), ("W", "W"), ("Tag +", "")] { + for (bad, says) in [("Nope + 'x'", "Nope"), ("number(Tag)", "Tag"), ("Tag +", "")] { let e = s.execute("sketch.edit_text", &json!({"link": link, "text_expression": bad})).unwrap_err().to_string(); assert!(e.contains(says), "{bad}: {e}"); } } + +/// #9281: a number parameter in a text's words shows its value in the design's units +/// (`number()` without the unit, with chosen decimals); a parameter edit rewrites the words +/// and the cut follows in the same rebuild, also after a save; a rename reaches the words and +/// deleting the parameter is refused while the text shows it. +#[test] +fn a_texts_words_show_a_number_parameter_and_follow_it() { + let typed = |words: &str| { + let mut s = Session::default(); + block_with_text(&mut s, json!({"text": words})); + no_trouble(&s); + volume(&mut s) + }; + let same = |a: f64, b: f64, what: &str| assert!((a - b).abs() < 1e-6, "{what}: {a} against {b} typed"); + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "Width", "expression": "85 mm"})); + run(&mut s, "parameters.add", json!({"name": "Tilt", "expression": "30 deg"})); + let r = block_with_text(&mut s, json!({"text_expression": "'W' + number(Width)"})); + let link = r["link"].as_str().unwrap().to_string(); + no_trouble(&s); + assert_eq!(words(&s, &link), "W85"); + same(volume(&mut s), typed("W85"), "W85"); + + run(&mut s, "parameters.change", json!({"name": "Width", "expression": "90 mm"})); + no_trouble(&s); + assert_eq!(words(&s, &link), "W90"); + let wide = typed("W90"); + same(volume(&mut s), wide, "W90"); + let mut again = reopened(&s); + no_trouble(&again); + same(volume(&mut again), wide, "W90 reopened"); + + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + run(&mut s, "sketch.edit_text", json!({"link": link, "text_expression": "number(Width, 1) + ' / ' + Tilt"})); + run(&mut s, "sketch.finish", json!({})); + no_trouble(&s); + assert_eq!(words(&s, &link), "90.0 / 30°"); + + run(&mut s, "parameters.rename", json!({"name": "Width", "new_name": "Span"})); + assert_eq!(word_expr(&s, &link), json!("number(Span, 1) + ' / ' + Tilt")); + let e = s.execute("parameters.delete", &json!({"name": "Span"})).unwrap_err().to_string(); + assert!(e.contains("Text"), "{e}"); + no_trouble(&s); + assert_eq!(words(&s, &link), "90.0 / 30°"); +} diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index 7c52cb6490..cc73d98f3a 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -320,6 +320,51 @@ mod tests { serde_json::from_value::(s.execute("view.status_items", &json!({})).unwrap()).unwrap().items } + #[test] + fn newest_status_uses_activation_not_row_order_and_filters_unpresented_effects() { + use crate::status_items::StatusId::{Continuity, Section, SurfaceAnalysis}; + let mut s = Session::default(); + s.execute("inspect.zebra", &json!({})).unwrap(); + s.execute("inspect.section", &json!({"plane":"XY"})).unwrap(); + s.execute("inspect.draft", &json!({"angle":7})).unwrap(); + assert_eq!(status(&mut s).iter().map(|item| item.id).collect::>(), [SurfaceAnalysis, Section]); + assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().command, "inspect.draft"); + s.execute("inspect.section", &json!({"plane":"XZ"})).unwrap(); + assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().id, Section); + let before = (s.revision, s.undo.len(), s.doc.to_json(), s.status_activation.clone()); + assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().command, "inspect.draft"); + assert_eq!((s.revision, s.undo.len(), s.doc.to_json(), s.status_activation.clone()), before); + assert!(s.execute("inspect.zebra", &json!({"stripes":-1})).is_err()); + assert_eq!(s.status_activation, before.3); + s.execute("inspect.draft", &json!({"clear":true})).unwrap(); + assert!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).is_none()); + s.execute("inspect.zebra", &json!({})).unwrap(); + assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().command, "inspect.zebra"); + } + + #[test] + fn newest_status_tracks_independent_continuity_and_policy_normalisation() { + use crate::status_items::StatusId::{Continuity, SurfaceAnalysis}; + for continuity_first in [false, true] { + let mut s = Session::default(); + boxed(&mut s, 10.0); + s.set_single_surface_analysis(false); + let shading = ("inspect.zebra", json!({})); + let seams = ("inspect.continuity", json!({"edges":[[5,0,0]],"samples":5})); + for (cmd, params) in if continuity_first { [seams, shading] } else { [shading, seams] } { + s.execute(cmd, ¶ms).unwrap(); + } + let latest = if continuity_first { "inspect.zebra" } else { "inspect.continuity" }; + assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().command, latest); + s.set_single_surface_analysis(true); + assert_eq!(s.newest_status_item(&[SurfaceAnalysis]).unwrap().command, latest); + s.set_single_surface_analysis(false); + let clear = s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().clear; + s.execute(&clear.command, &json!({"clear":true})).unwrap(); + assert!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).is_none()); + } + } + #[test] fn status_items_zero_seam_continuity_never_leaves_a_phantom_winner() { use crate::status_items::StatusId; diff --git a/crates/engine/src/crash.rs b/crates/engine/src/crash.rs index a56d2e089b..e307c645d1 100644 --- a/crates/engine/src/crash.rs +++ b/crates/engine/src/crash.rs @@ -365,9 +365,22 @@ pub fn phase(step: &str) { j.write(&line); j.phase = Some(line); j.phase_step = Some(step.to_string()); + j.running |= step == RUNNING; } } +/// A step of getting a window's graphics up (egui-wgpu's `window surface`, `surface configure`, +/// …): the [`phase`] until the app is [`RUNNING`], ignored after. A window opened later (the +/// agent chat on its own, say) goes through the same steps, and must not make a crash an hour +/// later read as a crash during startup (#8776). +pub fn startup_step(step: &str) { + let Some(s) = STATE.get() else { return }; + if s.journal.lock().is_ok_and(|j| j.running) { + return; + } + phase(step); +} + /// Where a native crash handler writes what it caught for this run: a small JSON file /// (`code`, `name`, `address`, `module`, `offset`, `detail`, `thread`, and from macOS /// `thread_name` and `frames`) and a minidump. The next @@ -622,6 +635,8 @@ struct Journal { /// The latest startup step's line and name, and the renderer's line (kept across rebases). phase: Option, phase_step: Option, + /// The app reached [`RUNNING`] once: later startup steps are not startup ([`startup_step`]). + running: bool, environment: Option, } @@ -642,6 +657,7 @@ impl Journal { running_at: None, phase: None, phase_step: None, + running: false, environment: None, }; j.rebase(&json!("new"), 0); @@ -889,6 +905,7 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) { running_at: None, phase: None, phase_step: None, + running: false, environment: None, }), known: Mutex::new(list(dir).into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect()), diff --git a/crates/engine/src/crash_tests.rs b/crates/engine/src/crash_tests.rs index 1c13991eeb..0c4608562f 100644 --- a/crates/engine/src/crash_tests.rs +++ b/crates/engine/src/crash_tests.rs @@ -50,6 +50,15 @@ fn child() { } std::process::abort(); } + // Up and running, then a second window's surface (the agent chat on its own), then dying. + if mode == "late_surface" { + startup_step("window surface"); + startup_step("surface configure"); + phase(RUNNING); + startup_step("window surface"); + startup_step("surface configure"); + std::process::abort(); + } set_environment("Renderer: test"); let mut s = Session { crash_journal: true, ..Default::default() }; s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap(); @@ -443,6 +452,21 @@ fn a_crash_signal_is_reported_with_its_thread_and_its_walk_up_the_stack() { let _ = fs::remove_dir_all(&d); } +#[test] +fn a_window_surface_after_startup_is_not_a_startup_crash() { + let d = fresh_dir("late_surface"); + let (_, ok) = run_child(&d, "late_surface"); + assert!(!ok, "the child aborted"); + let (_, ok) = run_child(&d, "none"); + assert!(ok); + let (s, v) = only_report(&d); + assert_eq!(s.kind, "unclean_exit"); + assert!(!s.message.contains("during startup"), "{}", s.message); + assert!(s.message.contains("between commands"), "{}", s.message); + assert!(v.get("startup_phase").is_none(), "{v}"); + let _ = fs::remove_dir_all(&d); +} + #[test] fn startup_steps_survive_a_rebase_and_running_ends_startup() { let d = fresh_dir("rebase"); diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 48b5b269b9..22194765e8 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -90,7 +90,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)], "sketch.intersection_curve" => &[("a", Here), ("b", Here)], "inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)], - "parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)], + "parts.insert" | "parts.fastener" => &[("at", Picks), ("point", Make), ("direction", Dir)], "appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)], "solid.move" => &[("bodies", Body), ("translate", Delta), ("axis", Dir), ("origin", Moved)], "solid.combine" => &[("target", Body), ("tools", Body)], diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 319fa753d4..3b0380a78b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -185,13 +185,15 @@ struct BodyRename { names_only: bool, } -/// Bodies or component placements copied in one design, for pasting into another -/// (`component.paste_from_design`): that design's placed model as it was when they were copied, +/// Bodies, component placements or timeline features copied in one design, for pasting into another: +/// that design's model as it was when they were copied, /// so the copy is what was copied, whatever happens to that design (or its tab) after. #[derive(Clone, Debug)] pub struct ForeignCopy { /// The design's name (it names what the paste adds). pub design: String, + /// Timeline features, in their original order (`timeline.paste` with `from_design`). + pub features: Vec, /// Its placed model: what its Browser and view showed. pub world: Arc, /// The bodies copied, as its Browser names them (a body seen in another placement under the @@ -219,6 +221,7 @@ impl ForeignCopy { let placed = placements.iter().filter_map(|p| session.occurrence_placement(p).ok()).map(|p| (p.component, p.occurrence, p.world)).collect(); ForeignCopy { design: session.doc.name.clone(), + features: Vec::new(), world: session.world_state(), bodies, names, @@ -229,6 +232,22 @@ impl ForeignCopy { pending_moves: session.pending_moves.clone(), } } + + /// Timeline Copy's snapshot. No placed body geometry is needed to paste sketches. + pub fn of_features(session: &Session) -> ForeignCopy { + ForeignCopy { + design: session.doc.name.clone(), + features: session.clipboard.clone(), + world: session.model.state(), + bodies: Vec::new(), + names: Vec::new(), + placements: Vec::new(), + placed: Vec::new(), + doc: session.doc.clone(), + model: session.model.clone(), + pending_moves: session.pending_moves.clone(), + } + } } /// One undo step. @@ -412,6 +431,8 @@ pub struct Session { /// Revision increments caused only by visibility. Comparing both counters proves that /// every intervening increment left an editor's document and model untouched. visibility_revision: u64, + /// [`Document::browser_stamp`] when the browser's groups and order were last checked. + browser_stamp: [u64; 6], /// This open design's id for programs that follow it (`document.revision`, #6646): kept /// while it stays open, through saves and renames; another for each design started or opened. pub document_id: String, @@ -472,8 +493,10 @@ pub struct Session { /// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not /// part of the design and not an undo step. pub continuity: Vec, - /// Transient activation order only; effect settings remain in their existing fields. + /// Oldest active slot first; replacing an effect retains its row position. status_order: Vec, + /// Successful source activations, independently of row order and sharing policy. + status_activation: Vec, single_surface_analysis: bool, latest_analysis: Option, /// What is hidden or shown in the view (`browser.visibility`): saved with the design and an @@ -628,6 +651,7 @@ impl Session { log: Vec::new(), revision: 1, visibility_revision: 0, + browser_stamp: [0; 6], document_id: cmd::inspect::new_document_id(), followed: None, auto_project: true, @@ -656,6 +680,7 @@ impl Session { analysis: None, continuity: Vec::new(), status_order: Vec::new(), + status_activation: Vec::new(), single_surface_analysis: true, latest_analysis: None, checkpoints: Vec::new(), @@ -924,6 +949,15 @@ impl Session { } self.revision += 1; self.prune_occurrence_state(); + // Looked at only when what the groups can name has changed, not on every refresh. + let stamp = self.doc.browser_stamp(); + if stamp != self.browser_stamp { + if let Some((groups, order)) = self.doc.browser_pruned() { + let d = self.doc_mut(); + (d.browser_groups, d.browser_order) = (groups, order); + } + self.browser_stamp = self.doc.browser_stamp(); + } // Joints follow the geometry their origins snap to. if !self.doc.assembly.joints.is_empty() { // A solve that moves parts changes what features reading where a part is placed @@ -1104,14 +1138,15 @@ impl Session { } } - /// Only persistent render intent, part properties or appearances changed. Never evaluate or - /// clear a stale model here. + /// Only persistent render intent, part properties, appearances or the browser's groups changed. + /// Never evaluate or clear a stale model here. fn render_only_change(&self, before: &Snapshot) -> bool { self.active_sketch == before.active_sketch && self.pending_moves == before.pending_moves && (self.doc.differs_only_in_render(&before.doc) || self.doc.differs_only_in_part_properties(&before.doc) - || self.doc.differs_only_in_appearance(&before.doc)) + || self.doc.differs_only_in_appearance(&before.doc) + || self.doc.differs_only_in_browser(&before.doc)) } /// Only what is hidden or shown changed (`browser.visibility`): the document and the model @@ -1478,6 +1513,7 @@ impl Session { log: Vec::new(), revision: self.revision, visibility_revision: self.visibility_revision, + browser_stamp: self.browser_stamp, document_id: self.document_id.clone(), followed: None, auto_project: self.auto_project, @@ -1506,6 +1542,7 @@ impl Session { analysis: None, continuity: Vec::new(), status_order: Vec::new(), + status_activation: Vec::new(), single_surface_analysis: self.single_surface_analysis, latest_analysis: None, checkpoints: Vec::new(), @@ -1972,6 +2009,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool { point_clouds, param_comments, materials, + print_faces, user_materials, appearances, body_components, @@ -2007,6 +2045,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool { && *point_clouds == b.point_clouds && *param_comments == b.param_comments && *materials == b.materials + && *print_faces == b.print_faces && *user_materials == b.user_materials && *appearances == b.appearances && *body_components == b.body_components diff --git a/crates/engine/src/measure_centre_tests.rs b/crates/engine/src/measure_centre_tests.rs index 9c87130a87..36e0eda924 100644 --- a/crates/engine/src/measure_centre_tests.rs +++ b/crates/engine/src/measure_centre_tests.rs @@ -159,6 +159,8 @@ fn two_holes_measure_centre_to_centre_with_min_and_max() { // One rim: Ø, R and circumference first-class. let r = measure(&mut s, vec![ea]); assert!(close(&r["diameter"], 6.0, 1e-9) && close(&r["radius"], 3.0, 1e-9) && close(&r["circumference"], TAU * 3.0, 1e-9), "{r}"); + // Its length is its circumference, not the length of the polyline drawn for it (#9086). + assert!(close(&r["items"][0]["length_mm"], TAU * 3.0, 1e-9), "{r}"); assert_eq!(r["items"][0]["round"], "circle", "{r}"); } @@ -259,9 +261,11 @@ fn sketch_circle_arc_and_slot() { run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": [34, 0], "sweep": 90}))["curves"][0].as_str().unwrap().to_string(); let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c})]); assert!(close(&r["diameter"], 10.0, 1e-9) && close(&r["radius"], 5.0, 1e-9) && close(&r["circumference"], 10.0 * PI, 1e-9), "{r}"); + assert!(close(&r["items"][0]["length_mm"], 10.0 * PI, 1e-9), "{r}"); let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": a})]); assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["diameter"], 8.0, 1e-9), "{r}"); assert!(close(&r["angle_deg"], 90.0, 1e-9) && close(&r["arc_length"], 2.0 * PI, 1e-9), "{r}"); + assert!(close(&r["items"][0]["length_mm"], 2.0 * PI, 1e-9), "{r}"); assert_eq!(r["items"][0]["round"], "arc"); // Circle to arc: centre to centre. let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c}), json!({"type": "sketch_curve", "id": a})]); @@ -315,9 +319,23 @@ fn a_fillet_edge_is_an_arc() { let r = measure(&mut s, vec![e]); assert!(close(&r["radius"], 5.0, 1e-9) && close(&r["diameter"], 10.0, 1e-9), "{r}"); assert!(close(&r["angle_deg"], 90.0, 1e-6) && close(&r["arc_length"], 2.5 * PI, 1e-6), "{r}"); + assert!(close(&r["items"][0]["length_mm"], 2.5 * PI, 1e-6), "{r}"); assert!(close(&r["center"][0], 35.0, 1e-9) && close(&r["center"][1], 25.0, 1e-9), "{r}"); } +/// A cylinder's top circle is as long as its circumference, in the same reply: the length +/// comes from the exact circle, not from the polyline drawn for it, which runs inside it +/// (#9086, the report's own cylinder). +#[test] +fn a_cylinders_top_circle_is_as_long_as_its_circumference() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"})); + let e = edge(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p - Vec3::new(40.0, 10.0, 20.0)).len() - 5.0).abs() < 1e-6); + let r = measure(&mut s, vec![e]); + assert!(close(&r["circumference"], TAU * 5.0, 1e-9), "{r}"); + assert!(close(&r["items"][0]["length_mm"], TAU * 5.0, 1e-9), "{r}"); +} + /// A plate with holes through STEP: the holes come back as cylinders, and measure the same. #[test] fn step_round_trip_of_a_plate_with_holes() { diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs index ec6a34d1b1..8d6ac6cc79 100644 --- a/crates/engine/src/occurrences.rs +++ b/crates/engine/src/occurrences.rs @@ -582,8 +582,11 @@ impl Session { || self.doc.browser_groups.iter().any(|g| { g.component == component && g.folder == folder - && g.items.iter().any(|i| i == item) - && self.folder_hidden(&format!("{scope}/g{}", g.id)) + && g.has_item(item) + // A hidden group hides the groups inside it. + && std::iter::successors(Some(g.id), |id| self.doc.browser_group(*id).and_then(|g| g.parent)) + .take(solidcraft_doc::MAX_GROUP_NESTING + 1) + .any(|id| self.folder_hidden(&format!("{scope}/g{id}"))) }) } diff --git a/crates/engine/src/schema/check.rs b/crates/engine/src/schema/check.rs index fa2ff6d7bc..4890a25bda 100644 --- a/crates/engine/src/schema/check.rs +++ b/crates/engine/src/schema/check.rs @@ -28,6 +28,9 @@ pub enum ParamCheck { } impl ParamCheck { + /// Every level, least checking first. + pub const ALL: [ParamCheck; 3] = [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict]; + pub fn parse(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "off" => Some(ParamCheck::Off), @@ -36,6 +39,15 @@ impl ParamCheck { _ => None, } } + + /// The level's word, as [`ParamCheck::parse`] reads it (the control channel's `param_check`). + pub fn name(self) -> &'static str { + match self { + ParamCheck::Off => "off", + ParamCheck::Warn => "warn", + ParamCheck::Strict => "strict", + } + } } /// The JSON Schema (2020-12) of what deserializes into `T`: a command's parameters. @@ -129,6 +141,16 @@ pub(crate) fn whole(message: impl std::fmt::Display) -> String { format!("{WHOLE}{message}") } +/// Marks a deserializer's message that the handler gives after the key and a colon, as it is +/// (serde's own wording for a saved reference, such as "missing field `at`", which +/// [`parse_issue`] would otherwise reword). +const KEYED: char = '\u{3}'; + +/// `message`, to follow the key and a colon as it is (see [`KEYED`]). +pub(crate) fn keyed(message: impl std::fmt::Display) -> String { + format!("{KEYED}{message}") +} + /// Marks a deserializer's message that carries its own path: an error from a flattened /// `FeatureCommon`, which serde can't locate. The path (one of its field names) and the error /// follow as JSON. @@ -233,6 +255,9 @@ pub(crate) fn parse_issue(e: &serde_path_to_error::Error) -> Some((field, inner)) => (format!("{path}.{field}"), inner), None => (path, inner), }; + if let Some(after) = inner.strip_prefix(KEYED) { + return Issue { path: path.clone(), message: truncate(&format!("`{path}`: {after}"), MAX_WHOLE_LEN), suggestion: None }; + } if let Some(complete) = inner.strip_prefix(WHOLE) { // The handler's own message, which names the caller's value: in full up to a generous // cap, since the contract also checks keys the handler ignores (`side` without `thin`). @@ -260,6 +285,14 @@ pub(crate) fn parse_issue(e: &serde_path_to_error::Error) -> Issue { path, message: truncate(&message, MAX_MESSAGE_LEN), suggestion: None } } +/// What is wrong with `params` for the command `id` by its typed contract: the issues a call gets +/// at [`ParamCheck::Warn`] (the first is the refusal at [`ParamCheck::Strict`]). None for a +/// command without a contract, or an id the registry lacks (a runner in another process may be +/// another version). +pub fn issues(id: &str, params: &Value) -> Vec { + crate::cmd::find_command(id).and_then(|c| c.contract).map(|c| c.check(params)).unwrap_or_default() +} + /// Run `handler` as the command `id`, checking the call against the contract `id` publishes /// (its [`CommandSpec::contract`](crate::cmd::CommandSpec::contract)), so the contract enforced is /// always the one described. With the level off and no audit recorder, this is exactly diff --git a/crates/engine/src/schema/construct.rs b/crates/engine/src/schema/construct.rs new file mode 100644 index 0000000000..edd0917358 --- /dev/null +++ b/crates/engine/src/schema/construct.rs @@ -0,0 +1,239 @@ +//! Contracts for the commands in `cmd/construct.rs`: construction axes, points and planes built +//! from model geometry. Their reply is a [`super::features::FeatureResult`]. +//! +//! Every reference is a [`GeoRefParam`]; its description says the forms, and each key's says +//! what the evaluation needs it to be (a plane, a line, a point…). A reference of the wrong kind +//! is refused when the feature is evaluated, which fails the command. + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::types::{ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present}; + +/// Add a construction axis along the axis of a cylinder, cone or torus face, or of a circular +/// edge. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AxisThroughCylinderParams { + /// The face or circular edge whose axis it is. Any line works: a straight edge or sketch + /// line, X, Y, Z or a construction axis gives itself. + pub face: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction axis square to a face, through a point. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AxisNormalToFaceParams { + /// The face: a plane, or a planar, cylindrical, conical, spherical or toroidal face. + pub face: GeoRefParam, + /// The point the axis goes through, square to the face where the face is nearest it: a + /// vertex, a construction point, a sketch point, a fixed point, or the centre of a circular + /// edge, a sketch circle or arc, or a spherical or toroidal face. Not on the axis of a round + /// face. + pub point: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction axis where two planes meet. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AxisTwoPlanesParams { + /// The first plane or planar face. + pub a: GeoRefParam, + /// The second plane or planar face, not parallel to the first. + pub b: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction axis through two points, from `a` towards `b`. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AxisTwoPointsParams { + /// The first point: a vertex, a construction point, a sketch point, a fixed point, or the + /// centre of a circular edge, a sketch circle or arc, or a spherical or toroidal face. + pub a: GeoRefParam, + /// The second point, somewhere else. + pub b: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction axis along a straight edge or line. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AxisEdgeParams { + /// The line: a straight edge, a sketch line, X, Y, Z or a construction axis (a cylinder, cone + /// or torus face gives its axis). + pub edge: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction point at a vertex or point. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PointVertexParams { + /// The point: a vertex, a construction point, a sketch point, a fixed point, or the centre of + /// a circular edge, a sketch circle or arc, or a spherical or toroidal face. + pub point: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction point where two lines meet, or midway between their closest points. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PointTwoEdgesParams { + /// The first line: a straight edge, a sketch line, X, Y, Z or a construction axis (a circular + /// edge or a cylinder, cone or torus face gives its axis). + pub a: GeoRefParam, + /// The second line, not parallel to the first. + pub b: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction point where three planes meet. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PointThreePlanesParams { + /// The first plane or planar face. + pub a: GeoRefParam, + /// The second plane or planar face, not parallel to the first. + pub b: GeoRefParam, + /// The third plane or planar face, not parallel to the line where the first two meet. + pub c: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction point at the centre of a circle, sphere or torus. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PointCenterParams { + /// A circular edge, a sketch circle or arc, or a spherical or toroidal face. + pub of: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction point where a line meets a plane. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PointEdgePlaneParams { + /// The line: a straight edge, a sketch line, X, Y, Z or a construction axis (a circular edge + /// or a cylinder, cone or torus face gives its axis), not parallel to the plane. + pub edge: GeoRefParam, + /// The plane or planar face. + pub plane: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction point part way along a path. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PointAlongPathParams { + /// The path: an edge or a sketch curve, with some length. + pub path: GeoRefParam, + /// How far along the path, as a fraction of its length: 0 at its start, 1 at its end (a + /// value outside is taken as the nearer end). Default: 0.5. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "NumberExpr")] + pub t: Option, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: ConstructionCommon, +} + +/// Add a construction plane halfway between two planes: midway between parallel ones, else +/// through the line where they meet, halving the angle between them. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MidplaneParams { + /// The first plane or planar face; between parallel planes, the new plane faces as this one. + pub a: GeoRefParam, + /// The second plane or planar face. + pub b: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +/// Add a construction plane through two lines in one plane. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PlaneTwoEdgesParams { + /// The first line: a straight edge, a sketch line, X, Y, Z or a construction axis (a circular + /// edge or a cylinder, cone or torus face gives its axis). + pub a: GeoRefParam, + /// The second line, in one plane with the first but not on the same line. + pub b: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +/// Add a construction plane through three points. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PlaneThreePointsParams { + /// The first point (the plane's origin): a vertex, a construction point, a sketch point, a + /// fixed point, or the centre of a circular edge, a sketch circle or arc, or a spherical or + /// toroidal face. + pub a: GeoRefParam, + /// The second point. + pub b: GeoRefParam, + /// The third point, not on the line through the first two. + pub c: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +/// Add a construction plane that touches a face at a point. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PlaneTangentParams { + /// The face: a plane, or a planar, cylindrical, conical, spherical or toroidal face. + pub face: GeoRefParam, + /// Where the plane touches the face, in mm: the point of the face nearest it. Not on the + /// axis of a round face. Default, for a face picked by a point: that point; required + /// otherwise. + #[serde(default)] + pub at: Option, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +/// Add a construction plane square to a path, part way along it. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PlaneAlongPathParams { + /// The path: an edge or a sketch curve, with some length. + pub path: GeoRefParam, + /// How far along the path, as a fraction of its length: 0 at its start, 1 at its end (a + /// value outside is taken as the nearer end). Default: 0.5. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "NumberExpr")] + pub t: Option, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +/// Add a construction plane that holds a line and is square to a plane. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PlanePerpendicularParams { + /// The plane or planar face the new plane is square to. + pub plane: GeoRefParam, + /// The line it holds: a straight edge, a sketch line, X, Y, Z or a construction axis (a + /// circular edge or a cylinder, cone or torus face gives its axis), not square to `plane`. + pub line: GeoRefParam, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +#[cfg(test)] +#[path = "construct_tests.rs"] +mod tests; diff --git a/crates/engine/src/schema/construct_tests.rs b/crates/engine/src/schema/construct_tests.rs new file mode 100644 index 0000000000..f461b6ef6c --- /dev/null +++ b/crates/engine/src/schema/construct_tests.rs @@ -0,0 +1,133 @@ +use serde_json::{Value, json}; + +use super::{ + AxisEdgeParams, AxisNormalToFaceParams, AxisThroughCylinderParams, AxisTwoPlanesParams, AxisTwoPointsParams, MidplaneParams, + PlaneAlongPathParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams, PointAlongPathParams, + PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams, +}; +use crate::Session; +use crate::schema::features::FeatureResult; +use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms}; +use crate::schema::{ParamCheck, input_schema_for, output_schema_for}; + +#[test] +fn schemas_and_the_key_check_agree() { + assert_keys(input_schema_for::(), &["face", "name", "participants"]); + assert_keys(input_schema_for::(), &["face", "name", "participants", "point"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["edge", "name", "participants"]); + assert_keys(input_schema_for::(), &["name", "participants", "point"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "c", "name", "participants"]); + assert_keys(input_schema_for::(), &["name", "of", "participants"]); + assert_keys(input_schema_for::(), &["edge", "name", "participants", "plane"]); + assert_keys(input_schema_for::(), &["name", "participants", "path", "t"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); + assert_keys(input_schema_for::(), &["a", "b", "c", "name", "participants"]); + assert_keys(input_schema_for::(), &["at", "face", "name", "participants"]); + assert_keys(input_schema_for::(), &["name", "participants", "path", "t"]); + assert_keys(input_schema_for::(), &["line", "name", "participants", "plane"]); +} + +#[test] +fn strict_keeps_todays_errors() { + assert_strict_keeps_todays_errors(&[ + ("construct.axis.edge", json!({"edge": 5})), + ("construct.axis.edge", json!({"edge": {"edge": "x"}})), + ("construct.axis.edge", json!({"edge": null})), + ("construct.axis.through_cylinder", json!({"face": {"type": "bogus"}})), + ("construct.axis.through_cylinder", json!({"face": {"type": "face", "at": [1, 2]}})), + ("construct.axis.two_points", json!({"a": {"sketch": true, "point": "p1"}, "b": {"point": [0, 0, 0]}})), + ("construct.axis.two_points", json!({"a": {"sketch": 1, "point": [1, 2, 3]}, "b": {"point": [0, 0, 0]}})), + ("construct.point.vertex", json!({"point": {"sketch": 1}})), + ("construct.point.vertex", json!({"point": {"type": "point", "p": false}})), + ("construct.point.vertex", json!({"point": {"type": "point"}})), + ("construct.point.vertex", json!({"point": {"construct_point": 5}})), + ("construct.plane.midplane", json!({"a": {"plane": 2.5}, "b": "XY"})), + ("construct.plane.midplane", json!({"a": {"plane": -1}, "b": "XY"})), + ("construct.plane.midplane", json!({"a": {"plane": null}, "b": "XY"})), + ("construct.plane.midplane", json!({"a": {"plane": {"normal": [0, 0, 0]}}, "b": "XY"})), + ("construct.plane.midplane", json!({"a": {"plane": {"x_dir": [1, 0, 0], "y_dir": [2, 0, 0], "normal": [0, 0, 1]}}, "b": "XY"})), + ("construct.plane.midplane", json!({"a": {"plane": {"origin": [1, 2], "normal": [0, 0, 1]}}, "b": "XY"})), + ("construct.plane.midplane", json!({"a": {"plane": {"origin": null, "normal": [0, 0, 1]}}, "b": "XY"})), + ]); +} + +/// Every construction command answers with `FeatureResult`, and its contract takes every form +/// these calls use (in Warn, a contract that refused one would still run the command, so the +/// replies are checked for warnings too). +#[test] +fn construction_features_reply_with_feature_result() { + let schema = output_schema_for::(); + let schema = schema.as_value(); + let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() }; + let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + // A 10 mm cube at the origin, a cylinder beside it and a sketch with a line. + run("solid.box", json!({"length": 10, "width": 10, "height": 10})); + run("solid.cylinder", json!({"radius": 5, "height": 10, "base": [30, 0, 0]})); + let sketch = run("sketch.create", json!({"plane": "XY", "name": "Guide"})); + let line = run("sketch.line", json!({"points": [[0, -20], [20, -20]]})); + run("sketch.finish", json!({})); + let (sketch, curve, point) = (sketch["sketch"].clone(), line["curves"][0].clone(), line["created"]["points"][0].clone()); + assert!(sketch.is_u64() && curve.is_string() && point.is_string(), "{sketch} {line}"); + let replies = vec![ + run("construct.point.vertex", json!({"point": {"vertex": [10, 10, 10]}, "name": "Corner"})), + run("construct.point.vertex", json!({"point": {"sketch": "Guide", "point": point}})), + run("construct.point.vertex", json!({"point": {"edge": [35, 0, 10]}})), + run("construct.point.two_edges", json!({"a": {"axis": "X"}, "b": {"axis": "y"}})), + run("construct.point.three_planes", json!({"a": "XY", "b": {"plane": "XZ"}, "c": {"face": [10, 5, 5]}})), + run("construct.point.center", json!({"of": {"edge": [35, 0, 10]}})), + run("construct.point.edge_plane", json!({"edge": {"axis": "Z"}, "plane": {"plane": {"origin": [0, 0, "3 mm"], "normal": [0, 0, 1]}}})), + run("construct.point.along_path", json!({"path": {"sketch": sketch, "curve": curve}, "t": "1 / 4"})), + run("construct.point.along_path", json!({"path": {"edge": [5, 0, 0]}})), + run("construct.axis.through_cylinder", json!({"face": {"face": [35, 0, 5]}, "name": "Bore"})), + run("construct.axis.normal_to_face", json!({"face": {"face": [5, 5, 10]}, "point": {"construct_point": "Corner"}})), + run("construct.axis.two_planes", json!({"a": "XY", "b": {"plane": {"x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}})), + run("construct.axis.two_points", json!({"a": {"type": "point", "p": [0, 0, 0]}, "b": {"point": {"x": 1, "y": 2, "z": 3}}})), + run("construct.axis.edge", json!({"edge": {"edge": [5, 0, 0]}, "participants": []})), + run("construct.plane.midplane", json!({"a": "XY", "b": {"face": [5, 5, 10]}, "name": "Middle"})), + run("construct.plane.two_edges", json!({"a": {"edge": [5, 0, 0]}, "b": {"edge": [0, 5, 0]}})), + run("construct.plane.three_points", json!({"a": {"point": [0, 0, 0]}, "b": {"vertex": [10, 0, 0]}, "c": {"sketch": sketch, "point": point}})), + run("construct.plane.tangent", json!({"face": {"face": [35, 0, 5]}})), + run("construct.plane.tangent", json!({"face": {"face": [30, 5, 5]}, "at": [30, 6, 5], "participants": ["Body1"]})), + run("construct.plane.along_path", json!({"path": {"edge": [5, 0, 0]}})), + run("construct.plane.perpendicular", json!({"plane": "XY", "line": {"edge": [5, 0, 0]}})), + // The offset and at-angle planes' contracts are in schema/features.rs; they run here + // because they use this design's `Top`, its faces and the `Bore` axis. + run("construct.plane.offset", json!({"base": "Top", "offset": 5, "name": "Shelf"})), + run("construct.plane.offset", json!({"base": "Shelf", "offset": "-2 mm"})), + run("construct.plane.offset", json!({"base": {"face": [5, 5, 10]}, "offset": 2})), + run("construct.plane.offset", json!({"base": "XZ", "through": {"vertex": [10, 10, 10]}, "offset": null})), + run("construct.plane.offset", json!({"base": {"origin": ["1 mm", 0, 0], "normal": [1, 0, 0]}, "offset": 1, "through": null})), + run("construct.plane.at_angle", json!({"base": "XY", "axis": "x", "angle": 30})), + run("construct.plane.at_angle", json!({"base": "XZ", "axis": {"edge": [5, 0, 0]}, "angle": "45 deg"})), + run("construct.plane.at_angle", json!({"base": {"face": [5, 5, 10]}, "axis": {"dir": [0, 1, 0]}, "angle": 10})), + run("construct.plane.at_angle", json!({"base": "XY", "axis": "Bore", "angle": 90})), + ]; + // Warn reports what the contract refuses and runs the command anyway: a construction feature + // makes no body, and a construction plane may not take an origin plane's name. + let no_body = run("construct.axis.edge", json!({"edge": {"axis": "X"}, "body_name": "Rod"})); + let warning = no_body["param_warnings"][0].as_str().unwrap_or_default(); + assert!(warning.starts_with("unknown key `body_name`"), "{no_body}"); + for r in replies.iter().chain([&no_body]) { + assert!(conforms(schema, schema, r), "{r} doesn't match {schema}"); + } + for r in &replies { + assert!(r.get("param_warnings").is_none(), "{r}"); + assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!([]), &json!([])), "{r}"); + } + // A construction plane's name that an origin plane has is refused with the same message, + // but strict names the command where the handler (`add_feature`, which doesn't know it) says + // `construct.plane`. + let refused = |level: ParamCheck| { + let mut s = Session { param_check: level, ..Session::default() }; + s.execute("construct.plane.midplane", &json!({"a": "XY", "b": {"plane": {"origin": [0, 0, 10], "normal": [0, 0, 1]}}, "name": " front "})) + .err() + .map(|e| e.to_string()) + }; + let message = "`front` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name"; + assert_eq!(refused(ParamCheck::Off), Some(crate::params::bad("construct.plane", message).to_string())); + assert_eq!(refused(ParamCheck::Strict), Some(crate::params::bad("construct.plane.midplane", message).to_string())); +} diff --git a/crates/engine/src/schema/features.rs b/crates/engine/src/schema/features.rs index 7ca08762b5..d524553d87 100644 --- a/crates/engine/src/schema/features.rs +++ b/crates/engine/src/schema/features.rs @@ -9,9 +9,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::types::{ - AngleExpr, AxisDir, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon, FromPoint, IdOrName, LengthExpr, - NumberExpr, OperationParam, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint, feature_common, - present, + AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon, FromPoint, + GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, + SketchRef, StringList, ThinSideParam, ToPoint, feature_common, present, }; /// Add a box primitive. @@ -367,6 +367,41 @@ pub struct ScaleParams { pub participants: Option, } +/// Add a construction plane parallel to `base`: `offset` away along its normal, or through a +/// point. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OffsetPlaneParams { + /// The plane it is parallel to. + pub base: PlaneOrFace, + /// How far from `base`, along its normal (negative: the other way). Required unless + /// `through` is given; not with `through` (`null` counts as not given). + #[serde(default)] + pub offset: Option, + /// A point the plane goes through instead: a vertex, a construction point, a sketch point, a + /// fixed point, or the centre of a circular edge, a sketch circle or arc, or a spherical or + /// toroidal face. `null` counts as not given. + #[serde(default)] + pub through: Option, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + +/// Add a construction plane: `base` turned by `angle` about an axis. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PlaneAtAngleParams { + /// The plane to turn. + pub base: PlaneOrFace, + /// The axis it turns about. A picked edge's or face's axis points the way its largest + /// component is positive. + pub axis: AxisLine, + /// How far to turn, counterclockwise looking back along `axis`'s direction. + pub angle: AngleExpr, + #[serde(flatten, deserialize_with = "feature_common")] + pub common: PlaneCommon, +} + /// What a feature command returns. #[derive(Debug, Serialize, JsonSchema)] pub struct FeatureResult { diff --git a/crates/engine/src/schema/features_tests.rs b/crates/engine/src/schema/features_tests.rs index 05d5255d45..09bfb432f3 100644 --- a/crates/engine/src/schema/features_tests.rs +++ b/crates/engine/src/schema/features_tests.rs @@ -1,7 +1,8 @@ use serde_json::{Value, json}; use super::{ - BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, PipeParams, ScaleParams, SphereParams, TorusParams, + BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, + ScaleParams, SphereParams, TorusParams, }; use crate::Session; use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle}; @@ -83,6 +84,8 @@ fn schemas_and_the_key_check_agree() { ); assert_keys(input_schema_for::(), &["bodies", "body_name", "body_names", "margin", "name", "participants"]); assert_keys(input_schema_for::(), &["bodies", "factor", "factors", "name", "origin", "participants"]); + assert_keys(input_schema_for::(), &["base", "name", "offset", "participants", "through"]); + assert_keys(input_schema_for::(), &["angle", "axis", "base", "name", "participants"]); } #[test] @@ -135,6 +138,24 @@ fn strict_keeps_todays_errors() { ("solid.scale", json!({"bodies": ["Body1"], "factors": [1, 2]})), ("solid.scale", json!({"bodies": ["Body1"], "factors": [1, 2, 3, 4]})), ("solid.scale", json!({"bodies": ["Body1"]})), + ("construct.plane.offset", json!({"base": true, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [1, 2]}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "fixed": "yes"}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "body": ""}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "body": "Body1"}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "index": -1}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "path": [0]}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"face": [0, 0, 0], "path": vec![1; 65]}, "offset": 5})), + ("construct.plane.offset", json!({"base": {"normal": [0, 0, 1], "origin": "here"}, "offset": 5})), + ("construct.plane.offset", json!({"base": "XY", "through": {"edge": "x"}})), + ("construct.plane.offset", json!({"base": "XY", "offset": true})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": {"dir": [0, 0, 0]}, "angle": 30})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": {"origin": "o", "dir": [0, 0, 1]}, "angle": 30})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": 5, "angle": 30})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": null, "angle": 30})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x", "point": [0, 0, 0]}, "angle": 30})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x"}, "angle": 30})), + ("construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": true})), ]); } diff --git a/crates/engine/src/schema/mod.rs b/crates/engine/src/schema/mod.rs index 9211bd71a9..04a13fbe40 100644 --- a/crates/engine/src/schema/mod.rs +++ b/crates/engine/src/schema/mod.rs @@ -55,9 +55,11 @@ mod tests; // The contracts, by command file. pub mod component; +pub mod construct; pub mod edit; pub mod features; pub mod inspect; +pub mod print_face; pub mod render; pub mod render_lights; pub mod render_ui; @@ -67,7 +69,7 @@ pub mod sketch_create; pub mod sketch_equation; pub mod view_navigation; -pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as}; +pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, issues, output_schema_for, run_checked, run_checked_as}; pub use types::*; /// A command's `run` that checks the call against the contract command `$id` publishes diff --git a/crates/engine/src/schema/print_face.rs b/crates/engine/src/schema/print_face.rs new file mode 100644 index 0000000000..88d10bc9dc --- /dev/null +++ b/crates/engine/src/schema/print_face.rs @@ -0,0 +1,74 @@ +//! Contracts and result types for the commands in `cmd/print_face.rs`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::types::Point3; + +/// Mark the flat face of a body at a point as the body's print face, or clear it. A slicer +/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`) lays +/// the body on its print face: turned so the face points down and lies at Z = 0, the centre of +/// mass over the origin. A body has one print face at most; marking another replaces it. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PrintFaceParams { + /// The body, by name. Solid bodies only: a mesh body can't have a print face yet. + pub body: String, + /// A point on the flat face to mark, in mm. Default: none, which clears the body's print face. + #[serde(default)] + pub point: Option, +} + +/// What `body.print_face` returns. +#[derive(Debug, Serialize, JsonSchema)] +pub struct PrintFaceResult { + /// The body. + pub body: String, + /// The persistent name of the face now marked. Absent when the print face was cleared. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub face: 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)] +mod tests { + use serde_json::json; + + use super::PrintFaceParams; + use crate::Session; + use crate::schema::input_schema_for; + use crate::schema::tests::assert_keys; + + #[test] + fn schemas_and_the_key_check_agree() { + assert_keys(input_schema_for::(), &["body", "point"]); + } + + #[test] + fn bad_calls_say_what_is_wrong_and_change_nothing() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap_or_else(|e| panic!("{e}")); + let undo = s.undo.len(); + let e = s.execute("body.print_face", &json!({"point": [20, 5, 2.5]})).unwrap_err().to_string(); + assert!(e.contains("body"), "{e}"); + let e = s.execute("body.print_face", &json!({"body": "Bracket", "point": [20, 5, 2.5]})).unwrap_err().to_string(); + assert!(e.contains("Bracket"), "{e}"); + let e = s.execute("body.print_face", &json!({"body": "Block", "point": "top"})).unwrap_err().to_string(); + assert!(e.contains("point"), "{e}"); + assert!(s.doc.print_faces.is_empty() && s.undo.len() == undo); + } + + #[test] + fn the_reply_names_the_marked_face_and_leaves_it_out_when_cleared() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap_or_else(|e| panic!("{e}")); + let r = s.execute("body.print_face", &json!({"body": "Block", "point": [20, 5, 2.5]})).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(r["body"], "Block"); + assert!(r["face"].as_str().is_some_and(|f| !f.is_empty()), "{r}"); + let r = s.execute("body.print_face", &json!({"body": "Block"})).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(r["body"], "Block"); + assert!(r.get("face").is_none(), "{r}"); + } +} diff --git a/crates/engine/src/schema/sketch_create.rs b/crates/engine/src/schema/sketch_create.rs index 4b079e9a67..1430694808 100644 --- a/crates/engine/src/schema/sketch_create.rs +++ b/crates/engine/src/schema/sketch_create.rs @@ -4,7 +4,58 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::types::{SketchRef, present}; +use super::types::{AngleExpr, SketchRef, present}; + +/// Create an infinite reference line in the active sketch. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConstructionLineParams { + /// The construction method. + pub mode: Option, + /// Point to pass through; for tangent_at it selects a contact on the curve. + pub at: Value, + /// Second point of a two-point construction line. + pub through: Option, + /// Point used to select the closest solution when several exist. + pub near: Option<[f64; 2]>, + /// Zero-based index in the deterministic list of solutions. + pub solution: Option, + /// A reference line for parallel and perpendicular constructions. + pub curve: Option, + /// Second reference curve for a common tangent or an angle bisector. + pub curve2: Option, + /// Choose the other tangent when two solutions exist. + pub alternate: Option, + /// Direction measured counter-clockwise from sketch X, in degrees. + pub angle: Option, + /// The sketch (default: the one being edited). + pub sketch: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ConstructionMethod { + Free, + Horizontal, + Vertical, + Angle, + Parallel, + Perpendicular, + TangentExternal, + TangentAt, + TangentTwo, + Bisector, +} + +/// The curves and constraints created, together with the sketch solve. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ConstructionLineResult { + pub curves: Vec, + pub constraints: Vec, + pub sketch: Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} /// Reverse a spline's direction: its last point becomes its first, and the curve runs the other /// way along the same path. Its points, and the constraints on them and on the curve, stay. Takes diff --git a/crates/engine/src/schema/sketch_create_tests.rs b/crates/engine/src/schema/sketch_create_tests.rs index ce79b6eabd..e9c69cf505 100644 --- a/crates/engine/src/schema/sketch_create_tests.rs +++ b/crates/engine/src/schema/sketch_create_tests.rs @@ -36,3 +36,25 @@ fn spline_reverse_reply_matches_its_schema() { assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}"); assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}"); } + +#[test] +fn construction_line_contracts_and_replies_match() { + use super::{ConstructionLineParams, ConstructionLineResult}; + assert_keys( + input_schema_for::(), + &["alternate", "angle", "at", "curve", "curve2", "mode", "near", "sketch", "solution", "through"], + ); + let schema = output_schema_for::(); + let schema = schema.as_value(); + let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() }; + s.execute("sketch.create", &json!({"plane":"XY"})).unwrap(); + for (id, p) in [ + ("sketch.construction_line", json!({"at":[0,0],"through":[5,5]})), + ("sketch.construction_line.horizontal", json!({"at":[0,10]})), + ("sketch.construction_line.angle", json!({"at":[10,10],"angle":30})), + ] { + let r = s.execute(id, &p).unwrap(); + assert!(conforms(schema, schema, &r), "{r}"); + } + assert!(s.execute("sketch.construction_line", &json!({"at":[0,0],"mode":"unknown"})).is_err()); +} diff --git a/crates/engine/src/schema/tests.rs b/crates/engine/src/schema/tests.rs index 3d5a3f00fa..20492bf729 100644 --- a/crates/engine/src/schema/tests.rs +++ b/crates/engine/src/schema/tests.rs @@ -361,6 +361,23 @@ fn the_level_survives_a_new_design() { assert_eq!(s.scratch().param_check, ParamCheck::Strict); } +#[test] +fn issues_are_read_from_the_commands_own_contract() { + let issues = super::issues("solid.box", &json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"})); + assert_eq!(messages(&issues), ["unknown key `operaton`; did you mean `operation`?"]); + assert_eq!(issues, box_contract().check(&json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"}))); + // No contract, or no such command: nothing to say. + assert!(super::issues("solid.fillet", &json!({"nonsense": 1})).is_empty()); + assert!(super::issues("no.such", &json!({"nonsense": 1})).is_empty()); +} + +#[test] +fn a_level_is_read_back_from_its_name() { + for level in ParamCheck::ALL { + assert_eq!(ParamCheck::parse(level.name()), Some(level)); + } +} + /// 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`, @@ -852,3 +869,177 @@ fn picks_are_still_looked_up_one_at_a_time() { let r = s.execute("solid.fillet", &fillet(json!([{"body": "Body1", "index": 0}]))).unwrap_or_else(|e| panic!("{e}")); assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}"); } + +/// A reference is accepted as `construct::geo` reads it before it looks anything up, and its +/// schema says the same, the first form that fits deciding, apart from what a schema can't say +/// (below). +#[test] +fn references_match_the_reference_reader() { + let schema = input_schema_for::(); + let schema = schema.as_value(); + for (v, accepted) in [ + (json!({"face": [1, 2, 3]}), true), + (json!({"edge": {"x": 1, "y": 2, "z": 3}}), true), + (json!({"vertex": [1, 2, 3], "body": "Body1"}), true), + (json!({"face": "F1", "edge": [1, 2, 3]}), true), + (json!({"face": [1e9, 0, 0]}), false), + (json!({"type": "point", "p": [1, 2, 3]}), true), + (json!({"type": "origin"}), true), + (json!({"type": "bogus"}), false), + (json!({"type": null, "face": [1, 2, 3]}), false), + (json!({"sketch": 3, "point": "p1"}), true), + (json!({"sketch": "Guide", "curve": "l1"}), true), + (json!({"sketch": 2.5, "point": [1, 2], "curve": "l1"}), true), + (json!({"sketch": true, "point": "p1"}), false), + (json!({"sketch": 3}), false), + (json!({"sketch": null, "point": [1, 2, 3]}), false), + (json!({"point": [1, 2, 3]}), true), + (json!({"point": "p1"}), false), + (json!({"plane": "XY"}), true), + (json!({"plane": 4}), true), + (json!({"plane": -4}), false), + (json!({"plane": {"normal": [0, 0, 1]}}), true), + (json!({"plane": {"origin": ["w", 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}), true), + (json!({"plane": {"x_dir": [1, 0, 0]}}), false), + (json!({"plane": {"origin": [0, 0], "normal": [0, 0, 1]}}), false), + (json!({"plane": 2.5, "axis": "X"}), false), + (json!({"point": [1, 2, 3], "plane": 2.5}), true), + (json!({"axis": "X"}), true), + (json!({"axis": 1, "construct_point": "P"}), true), + (json!({"construct_point": "P"}), true), + (json!({"construct_point": 1}), false), + (json!("Shelf"), true), + (json!(""), true), + (json!(5), false), + (json!(null), false), + (json!([1, 2, 3]), false), + (json!({}), false), + ] { + let form = match crate::cmd::geo_form(&v) { + Ok(crate::cmd::GeoForm::Sketch { sketch, part }) => crate::cmd::sketch_key(Some(sketch)).is_some() && part.is_some(), + Ok(crate::cmd::GeoForm::Plane(plane)) => parse::(plane.clone()).is_some(), + form => form.is_ok(), + }; + assert_eq!(form, accepted, "reader {v}"); + assert_eq!(parse::(v.clone()).is_some(), accepted, "{v}"); + assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}"); + } + // Where the schema can't follow the reader: a saved reference is read whole by serde, and an + // explicit plane's directions must make a plane. + for v in [json!({"type": "face", "at": "x"}), json!({"plane": {"normal": [0, 0, 0]}})] { + assert!(parse::(v.clone()).is_none() && conforms(schema, schema, &v), "{v}"); + } +} + +/// A plane is accepted as `features::plane_param` reads it before it looks anything up, and +/// so is a plane or face `base` (a face as `sketch::face::capture` reads it); their schemas say +/// the same, apart from directions that don't make a plane. +#[test] +fn planes_match_the_plane_readers() { + let plane = input_schema_for::(); + let plane = plane.as_value(); + let base = input_schema_for::(); + let base = base.as_value(); + let face = |extra: Value| { + let mut v = json!({"face": [1, 2, 3]}); + if let (Some(v), Some(extra)) = (v.as_object_mut(), extra.as_object()) { + v.extend(extra.clone()); + } + v + }; + for (v, as_plane, as_base) in [ + (json!("XY"), true, true), + (json!("anything"), true, true), + (json!(7), true, true), + (json!(u64::MAX), true, true), + (json!(-7), false, false), + (json!(7.5), false, false), + (json!(true), false, false), + (json!(null), false, false), + (json!([0, 0, 1]), false, false), + (json!({}), false, false), + (json!({"origin": [0, 0, "h"], "normal": [0, 0, 1]}), true, true), + (json!({"origin": {"x": 0, "y": 0, "z": 1}, "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), true, true), + (json!({"x_dir": [1, 0, 0], "y_dir": "y", "normal": [0, 0, 1]}), true, true), + (json!({"x_dir": [1, 0, 0]}), false, false), + (json!({"origin": {"x": 0, "y": 0}, "normal": [0, 0, 1]}), false, false), + (json!({"origin": null, "normal": [0, 0, 1]}), false, false), + (face(json!({})), false, true), + (face(json!({"normal": [0, 0, 1]})), true, true), + (face(json!({"body": "Body1", "index": 0, "path": [1, 2], "fixed": true})), false, true), + (face(json!({"body": "Body1"})), false, false), + (face(json!({"index": 0})), false, false), + (face(json!({"body": "", "index": 0})), false, false), + (face(json!({"body": "Body1", "index": -1})), false, false), + (face(json!({"body": "Body1", "index": 1.0})), false, false), + (face(json!({"path": [0]})), false, false), + (face(json!({"path": vec![1; 64]})), false, true), + (face(json!({"path": vec![1; 65]})), false, false), + (face(json!({"fixed": "yes"})), false, false), + (json!({"face": "x", "normal": [0, 0, 1]}), true, false), + (json!({"face": null}), false, false), + ] { + assert_eq!(parse::(v.clone()).is_some(), as_plane, "{v}"); + assert_eq!(conforms(plane, plane, &v), as_plane, "plane schema {v}"); + let reader = match v.get("face") { + Some(_) => crate::cmd::face_pick_form(&v).is_ok(), + None => as_plane, + }; + assert_eq!(reader, as_base, "reader {v}"); + assert_eq!(parse::(v.clone()).is_some(), as_base, "{v}"); + assert_eq!(conforms(base, base, &v), as_base, "base schema {v}"); + } + for v in [json!({"normal": [0, 0, 0]}), json!({"x_dir": [1, 0, 0], "y_dir": [2, 0, 0], "normal": [0, 0, 1]})] { + assert!(parse::(v.clone()).is_none() && conforms(plane, plane, &v), "{v}"); + } + // An id, an index or a path id written with a fraction (`7.0`) is refused by the readers but + // is an integer to JSON Schema (`conforms` reads integers as serde does, so it agrees with + // the readers above; a standard validator would not). + assert!(parse::(json!(7.0)).is_none() && parse::(face(json!({"path": [1.0]}))).is_none()); +} + +/// A plane at an angle's `axis` is accepted as `features::axis_line` reads it before it looks +/// anything up, and its schema says the same, apart from what a schema can't say (below). +#[test] +fn axis_lines_match_the_axis_reader() { + let schema = input_schema_for::(); + let schema = schema.as_value(); + for (v, accepted) in [ + (json!("X"), true), + (json!("Bore"), true), + (json!({"dir": [0, 0, 1]}), true), + (json!({"origin": [1, 2, 3], "dir": {"x": 0, "y": 1, "z": 0}}), true), + (json!({"origin": ["1 mm", 0, 0], "dir": [0, 0, 1]}), false), + (json!({"dir": [0, 0, 1], "type": "world", "axis": "X"}), true), + (json!({"edge": [1, 2, 3]}), true), + (json!({"face": [1, 2, 3], "dir": [0, 0, 1]}), true), + (json!({"face": "x", "dir": [0, 0, 1]}), false), + (json!({"type": "edge", "at": [1, 2, 3]}), true), + (json!({"type": "vertex", "at": [1, 2, 3]}), false), + (json!({"face": "x", "vertex": [1, 2, 3]}), false), + (json!({"edge": "x", "sketch": 1, "curve": "l1"}), false), + (json!({}), false), + (json!(5), false), + (json!(null), false), + (json!([0, 0, 1]), false), + ] { + assert_eq!(parse::(v.clone()).is_some(), accepted, "{v}"); + assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}"); + } + // Where the schema can't follow the reader: a saved edge is read whole by serde, and `dir` + // must not be zero. + for v in [json!({"type": "edge", "at": "x"}), json!({"dir": [0, 0, 0]})] { + assert!(parse::(v.clone()).is_none() && conforms(schema, schema, &v), "{v}"); + } +} + +/// A construction plane's name is refused when `features::check_plane_name` refuses it. (The +/// schema can't say which names: see `PlaneName`.) +#[test] +fn plane_names_match_check_plane_name() { + for name in ["Shelf", "XY", " top ", "Front\u{a0}", "RIGHT", "XYZ", "", "前"] { + let refused = crate::cmd::plane_name_problem(name); + assert_eq!(parse::(json!(name)).is_none(), refused.is_some(), "{name:?}"); + } + assert!(parse::(json!(5)).is_none()); +} diff --git a/crates/engine/src/schema/types.rs b/crates/engine/src/schema/types.rs index 7d0bd4521a..43c9402f97 100644 --- a/crates/engine/src/schema/types.rs +++ b/crates/engine/src/schema/types.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use solidcraft_doc::{Expr, Operation, ThinSide}; use solidcraft_geom::{Vec2, Vec3}; -use super::check::whole; +use super::check::{keyed, whole}; use crate::params; /// For optional fields whose key may be absent but not `null`: absent is `None` (with @@ -818,6 +818,311 @@ impl JsonSchema for SketchRef { } } +/// What `features::plane_param` says about `v` as a plane before it looks anything up, as a +/// complete message, or `None`. Names and ids are the handler's to look up. +fn plane_problem(v: &Value) -> Option { + let forms = || whole(format!("`plane` must be {}", crate::cmd::PLANE_FORMS)); + match v { + Value::String(_) => None, + Value::Number(n) => n.as_u64().map_or_else(|| Some(forms()), |_| None), + Value::Object(o) => { + // The handler evaluates each coordinate after reading it; an expression that doesn't + // evaluate is its to report. + if o.get("origin").is_some_and(|origin| PointExpr3::deserialize(origin).is_err()) { + return Some(whole("`origin` must be a point [x, y, z] of numbers or length expressions")); + } + crate::cmd::explicit_plane(v, Vec3::ZERO).map_or_else(|| Some(whole("plane needs x_dir and y_dir, or normal")), |_| None) + } + _ => Some(forms()), + } +} + +/// A plane, as `features::plane_param` reads it: an origin plane's name or alias, a construction +/// plane's name or feature id, or an explicit plane object. Kept as given, for the handler. +/// Whether a name or id names a plane is checked by the handler, which has the design. +#[derive(Clone, Debug, PartialEq)] +pub struct PlaneParam(pub Value); + +impl<'de> Deserialize<'de> for PlaneParam { + fn deserialize>(d: D) -> Result { + let v = Value::deserialize(d)?; + match plane_problem(&v) { + Some(why) => Err(D::Error::custom(why)), + None => Ok(PlaneParam(v)), + } + } +} + +impl JsonSchema for PlaneParam { + fn schema_name() -> Cow<'static, str> { + "Plane".into() + } + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let point = generator.subschema_for::(); + let origin = generator.subschema_for::(); + json_schema!({ + "description": "A plane: XY, XZ or YZ (or Top, Front or Right; any case), a construction plane's name or feature id, or an explicit plane {\"origin\"?, \"x_dir\", \"y_dir\"} or {\"origin\"?, \"normal\"}: directions [x, y, z] (two that aren't parallel, or a normal that isn't zero; the two directions win over normal when both are points) and an origin in mm, each coordinate a number or a length expression (default: the origin).", + "anyOf": [ + {"type": "string"}, + {"type": "integer", "minimum": 0, "maximum": u64::MAX}, + { + "type": "object", + "properties": {"origin": origin}, + "anyOf": [ + {"properties": {"x_dir": point, "y_dir": point}, "required": ["x_dir", "y_dir"]}, + {"properties": {"normal": point}, "required": ["normal"]} + ] + } + ] + }) + } +} + +/// What `construct::geo` says about `v` as a reference before it looks anything up, in the +/// contract's style (the key goes in front), or `None`. Sketches, axes and construction points +/// are the handler's to look up. +fn geo_problem(v: &Value) -> Option { + use crate::cmd::{GeoForm, GeoFormError}; + match crate::cmd::geo_form(v) { + Ok(GeoForm::Sketch { sketch, part }) => { + if crate::cmd::sketch_key(Some(sketch)).is_none() { + Some("must be a sketch id or name".into()) + } else { + part.is_none().then(|| crate::cmd::SKETCH_PART.into()) + } + } + Ok(GeoForm::Plane(plane)) => plane_problem(plane), + Ok(_) => None, + Err(GeoFormError::Saved(why)) => Some(keyed(why)), + Err(GeoFormError::Unknown) => Some(format!("must be {}", crate::cmd::GEO_REF_FORMS)), + } +} + +/// The reference types a saved reference's `type` names (`solidcraft_doc::construct::GeoRef`). +const SAVED_REFS: [&str; 11] = + ["point", "origin", "vertex", "edge", "face", "plane", "world", "axis", "construct_point", "sketch_point", "sketch_curve"]; + +/// `{key: point}`, unless it has `type` (then it is a saved reference). +fn picked_at(key: &str, point: &Schema) -> Value { + json!({"type": "object", "properties": {key: point}, "required": [key], "not": {"required": ["type"]}}) +} + +/// A reference to geometry for a construction axis, point or plane, as `construct::geo` reads +/// it. Kept as given, for the handler. What the reference must be (a plane, a line…) depends on +/// the key and is checked when the feature is evaluated; whether a sketch, axis or construction +/// point it names exists is checked by the handler. +#[derive(Clone, Debug, PartialEq)] +pub struct GeoRefParam(pub Value); + +impl<'de> Deserialize<'de> for GeoRefParam { + fn deserialize>(d: D) -> Result { + let v = Value::deserialize(d)?; + match geo_problem(&v) { + Some(why) => Err(D::Error::custom(why)), + None => Ok(GeoRefParam(v)), + } + } +} + +impl JsonSchema for GeoRefParam { + fn schema_name() -> Cow<'static, str> { + "GeoRef".into() + } + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let point = generator.subschema_for::(); + let plane = generator.subschema_for::(); + let sketch = generator.subschema_for::(); + let text = json!({"type": "string"}); + // The reader takes the first form that fits, in the order below; the `not`s keep a later + // form from taking an object an earlier one refuses. + let earlier = |keys: &[&str]| json!({"anyOf": keys.iter().map(|k| json!({"required": [k]})).collect::>()}); + json_schema!({ + "description": "Geometry to build from, the first of these forms that fits: a reference as saved ({\"type\": …}, as timeline.inspect gives one back); a point in mm on a face, edge or vertex of a body ({\"face\": [x, y, z]}, {\"edge\": …} or {\"vertex\": …}), kept by its persistent name; a sketch's point or curve ({\"sketch\": id or name, \"point\" or \"curve\": its id}); a fixed point {\"point\": [x, y, z]}; a plane {\"plane\": …}; an axis {\"axis\": X, Y, Z or a construction axis's name}; a construction point {\"construct_point\": name}; or a plane's name.", + "anyOf": [ + {"type": "object", "properties": {"type": {"enum": SAVED_REFS}}, "required": ["type"]}, + picked_at("face", &point), + picked_at("edge", &point), + picked_at("vertex", &point), + { + "type": "object", + "properties": {"sketch": sketch}, + "required": ["sketch"], + "anyOf": [{"properties": {"point": text}, "required": ["point"]}, {"properties": {"curve": text}, "required": ["curve"]}], + "not": earlier(&["type"]) + }, + {"type": "object", "properties": {"point": point}, "required": ["point"], "not": earlier(&["type", "sketch"])}, + {"type": "object", "properties": {"plane": plane}, "required": ["plane"], "not": earlier(&["type", "sketch"])}, + {"type": "object", "properties": {"axis": text}, "required": ["axis"], "not": earlier(&["type", "sketch", "plane"])}, + {"type": "object", "properties": {"construct_point": text}, "required": ["construct_point"], "not": earlier(&["type", "sketch", "plane"])}, + text + ] + }) + } +} + +/// A construction plane's `base`, as `construct.plane.offset` and `construct.plane.at_angle` +/// read it: a planar face of a body when it has `face` (as `sketch::face::capture` reads it), +/// otherwise a [`PlaneParam`]. Kept as given, for the handler. +#[derive(Clone, Debug, PartialEq)] +pub struct PlaneOrFace(pub Value); + +impl<'de> Deserialize<'de> for PlaneOrFace { + fn deserialize>(d: D) -> Result { + let v = Value::deserialize(d)?; + let problem = match v.get("face") { + Some(_) => crate::cmd::face_pick_form(&v).err().map(whole), + None => plane_problem(&v), + }; + match problem { + Some(why) => Err(D::Error::custom(why)), + None => Ok(PlaneOrFace(v)), + } + } +} + +impl JsonSchema for PlaneOrFace { + fn schema_name() -> Cow<'static, str> { + "PlaneOrFace".into() + } + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let point = generator.subschema_for::(); + let plane = generator.subschema_for::(); + let index_max = u64::try_from(usize::MAX).unwrap_or(u64::MAX); + json_schema!({ + "description": "A plane, or a planar face of a body {\"face\": [x, y, z] in world coordinates, \"body\"?, \"index\"?, \"path\"?, \"fixed\"?}, which the plane follows as the model changes (\"fixed\": true keeps it where the face is now). \"body\" (the picked body's world name) and \"index\" (the face's index in it) pick the face exactly and go together; \"path\" is the occurrence path of the body's placement, at most 64 ids.", + "anyOf": [ + { + "type": "object", + "properties": { + "face": point, + "fixed": {"type": "boolean"}, + "body": {"type": "string", "minLength": 1}, + "index": {"type": "integer", "minimum": 0, "maximum": index_max}, + "path": {"type": "array", "maxItems": 64, "items": {"type": "integer", "minimum": 1, "maximum": u64::MAX}} + }, + "required": ["face"], + "anyOf": [{"required": ["body", "index"]}, {"not": {"anyOf": [{"required": ["body"]}, {"required": ["index"]}]}}] + }, + {"anyOf": [plane], "not": {"type": "object", "required": ["face"]}} + ] + }) + } +} + +/// The line `construct.plane.at_angle` turns about, as `features::axis_line` reads it: an edge or +/// face of the model (as `construct::model_axis_param` reads it), else X, Y, Z, a construction +/// axis's name or `{origin?, dir}`. Kept as given, for the handler. Whether a picked edge or face +/// gives a line, or a name names an axis, is checked by the handler, which has the design. +#[derive(Clone, Debug, PartialEq)] +pub struct AxisLine(pub Value); + +impl<'de> Deserialize<'de> for AxisLine { + fn deserialize>(d: D) -> Result { + use crate::cmd::GeoForm; + use solidcraft_doc::construct::GeoRef; + let v = Value::deserialize(d)?; + let problem = if crate::cmd::is_model_axis(&v) { + geo_problem(&v).or_else(|| match crate::cmd::geo_form(&v) { + Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None, + _ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()), + }) + } else { + match &v { + Value::String(_) => None, + Value::Object(_) => crate::cmd::axis_line_object(&v).err().map(whole), + _ => Some(whole("`axis` is required")), + } + }; + match problem { + Some(why) => Err(D::Error::custom(why)), + None => Ok(AxisLine(v)), + } + } +} + +impl JsonSchema for AxisLine { + fn schema_name() -> Cow<'static, str> { + "AxisLine".into() + } + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let point = generator.subschema_for::(); + let saved = json!({"required": ["type"], "properties": {"type": {"enum": ["edge", "face"]}}}); + json_schema!({ + "description": "An axis: X, Y or Z (any case), a construction axis's name (where it is now), {\"origin\"?: [x, y, z] in mm (default: the origin), \"dir\": [x, y, z] that isn't zero}, or a straight or circular edge, or a cylinder, cone or torus face, of a body ({\"edge\": [x, y, z]} or {\"face\": [x, y, z]}, a point on it, or as saved: {\"type\": \"edge\" or \"face\", \"at\", \"name\"?}), whose axis the plane follows as the model changes.", + "anyOf": [ + {"type": "string"}, + { + "type": "object", + "properties": {"origin": point, "dir": point}, + "required": ["dir"], + "not": {"anyOf": [{"required": ["edge"]}, {"required": ["face"]}, saved]} + }, + picked_at("face", &point), + picked_at("edge", &point), + {"type": "object", "properties": {"type": {"enum": ["edge", "face"]}}, "required": ["type"]} + ] + }) + } +} + +/// A construction plane's name, which `features::check_plane_name` refuses when it names an +/// origin plane. Strict gives the same message but names the command that was called, where the +/// handler (`add_feature`, which isn't given it) says `construct.plane`. +#[derive(Clone, Debug, PartialEq)] +pub struct PlaneName(pub String); + +impl<'de> Deserialize<'de> for PlaneName { + fn deserialize>(d: D) -> Result { + match Value::deserialize(d)? { + Value::String(name) => match crate::cmd::plane_name_problem(&name) { + Some(why) => Err(D::Error::custom(whole(why))), + None => Ok(PlaneName(name)), + }, + _ => Err(D::Error::custom("must be text")), + } + } +} + +impl JsonSchema for PlaneName { + fn schema_name() -> Cow<'static, str> { + "PlaneName".into() + } + fn json_schema(_: &mut SchemaGenerator) -> Schema { + // The schema can't say which names are refused: a pattern would need a lookahead, which + // not every validator has. + json_schema!({ + "description": "The plane's name in the timeline: not XY, XZ, YZ, Top, Front or Right (any case, with or without spaces around it), which name the origin planes.", + "type": "string" + }) + } +} + +/// The keys `features::add_feature` reads that a construction axis or point takes besides its +/// references, flattened into its parameters with [`feature_common`]. Not [`FeatureCommon`]: it +/// makes no body, so `body_name` and `body_names` would do nothing. `participants` stays because +/// `timeline.redefine` passes a feature's own back. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] +pub struct ConstructionCommon { + /// The feature's name in the timeline. + #[serde(default)] + pub name: Option, + /// The bodies taking part, which every feature records; each an existing body or `*`. A + /// construction feature changes no body, whatever this lists. + #[serde(default)] + pub participants: Option, +} + +/// [`ConstructionCommon`] for a construction plane, whose name may not name an origin plane. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] +pub struct PlaneCommon { + #[serde(default)] + pub name: Option, + /// The bodies taking part, which every feature records; each an existing body or `*`. A + /// construction feature changes no body, whatever this lists. + #[serde(default)] + pub participants: Option, +} + /// The keys `features::add_feature` reads for every feature command, whatever else it takes: /// the feature's name, the new bodies' names and the bodies taking part. A feature command's /// parameters include it with @@ -849,11 +1154,12 @@ pub struct FeatureCommon { pub participants: Option, } -/// Deserializes a flattened [`FeatureCommon`] from the keys the parent doesn't take, and reports -/// a problem as if its fields were the parent's own, with the key in the message and in the -/// issue's path (serde's flatten otherwise loses the key). serde reads a flattened field after -/// all the parent's own, so its problems come after theirs, including a missing key. -pub fn feature_common<'de, D: Deserializer<'de>>(d: D) -> Result { +/// Deserializes a flattened [`FeatureCommon`] (or [`ConstructionCommon`], [`PlaneCommon`]) from +/// the keys the parent doesn't take, and reports a problem as if its fields were the parent's +/// own, with the key in the message and in the issue's path (serde's flatten otherwise loses the +/// key). serde reads a flattened field after all the parent's own, so its problems come after +/// theirs, including a missing key. +pub fn feature_common<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(d: D) -> Result { let v = Value::deserialize(d)?; serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e))) } diff --git a/crates/engine/src/schema/unconverted.txt b/crates/engine/src/schema/unconverted.txt index 20c8f7fdfb..bc165ff770 100644 --- a/crates/engine/src/schema/unconverted.txt +++ b/crates/engine/src/schema/unconverted.txt @@ -51,25 +51,6 @@ config.row config.rule 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 diff --git a/crates/engine/src/status_items.rs b/crates/engine/src/status_items.rs index e95a7cb5fc..a132303ec2 100644 --- a/crates/engine/src/status_items.rs +++ b/crates/engine/src/status_items.rs @@ -113,6 +113,14 @@ impl Session { } } self.single_surface_analysis = single; + let shading = self.analysis.is_some(); + let continuity = !self.continuity.is_empty(); + let section = self.section.is_some(); + self.status_activation.retain(|source| match source { + StatusId::SurfaceAnalysis => shading, + StatusId::Continuity => continuity, + StatusId::Section => section, + }); self.status_order.clear(); for item in &before { let id = match item.id { @@ -160,6 +168,10 @@ impl Session { } else { None }; + self.status_activation.retain(|id| *id == StatusId::Section || active(id)); + if let Some(id) = activated.filter(active) { + self.status_activated(id); + } self.latest_analysis = activated.filter(active).or(self.latest_analysis.filter(active)).or(remaining); self.status_slot(StatusId::SurfaceAnalysis, self.analysis.is_some() || (self.single_surface_analysis && !self.continuity.is_empty())); self.status_slot(StatusId::Continuity, !self.single_surface_analysis && !self.continuity.is_empty()); @@ -212,9 +224,39 @@ impl Session { items } + /// Most recently activated effect among the caller's currently presented slots. + /// Row position is deliberately independent: replacing an analysis retains its slot. + /// Effects assigned directly by embedders have no activation record; their declared + /// order provides a deterministic fallback without inventing session timestamps. + pub fn newest_status_item(&self, eligible: &[StatusId]) -> Option { + let items = self.status_items(); + let find = |source: StatusId| { + items + .iter() + .find(|item| { + eligible.contains(&item.id) + && match source { + StatusId::SurfaceAnalysis => item.id == StatusId::SurfaceAnalysis && item.command != "inspect.continuity", + StatusId::Continuity => item.command == "inspect.continuity", + StatusId::Section => item.id == StatusId::Section, + } + }) + .cloned() + }; + self.status_activation.iter().rev().find_map(|id| find(*id)).or_else(|| items.into_iter().rev().find(|item| eligible.contains(&item.id))) + } + + pub(crate) fn status_activated(&mut self, id: StatusId) { + self.status_activation.retain(|source| *source != id); + self.status_activation.push(id); + } + pub(crate) fn status_slot(&mut self, id: StatusId, active: bool) { if !active { self.status_order.retain(|slot| *slot != id); + if id == StatusId::Section { + self.status_activation.retain(|source| *source != id); + } } else if !self.status_order.contains(&id) { self.status_order.push(id); } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index eb92d7a41d..611151ba6d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -864,12 +864,16 @@ fn sketch_component_visible(s: &Session, id: u64) -> bool { /// 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). +/// them (#6804). So does a Hole at sketch points: shown, its points are drawn in the holes +/// (#9082). A Single hole snapped to one point (#2110) does not: it was placed on a face, not +/// built from the sketch, and hiding the sketch would take its other points from the next +/// Single hole's snap. pub fn sketch_consumed(s: &Session, id: u64) -> bool { - use solidcraft_doc::FeatureKind; + use solidcraft_doc::{FeatureKind, SketchPoints}; s.doc.features.iter().any(|f| { matches!(f.kind, FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } - | FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if sketch == id) + | FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } + | FeatureKind::Hole { points: Some(SketchPoints { sketch, single: false, .. }), .. } if sketch == id) }) } diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index e7626e7e8e..5dbae9b5c8 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -15,7 +15,7 @@ use solidcraft_geom::Vec3; use solidcraft_render::{AnalysisMode, Camera, Rgb, SECTION_CAP, Scene}; use wgpu::util::DeviceExt; -use crate::view::{AccessMap, Frame, GpuScene, GpuTarget, Renderer, analysis_colors, analysis_uniform}; +use crate::view::{AccessMap, DrawStats, Frame, GpuScene, GpuTarget, Renderer, analysis_colors, analysis_uniform}; use crate::{GpuError, Result, caught, error_scopes}; /// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual @@ -176,6 +176,17 @@ impl Capturer { /// [`Capturer::rgba`] with `highlight` drawn as the viewport draws its highlight scene /// (selection and hover feedback), over the model scene. pub fn rgba_highlighted(&self, scene: &Scene, highlight: Option, cam: &Camera, width: u32, height: u32) -> Result> { + self.draw(scene, highlight, cam, width, height).map(|(rgba, _)| rgba) + } + + /// [`Capturer::rgba`] and what the picture drew ([`DrawStats`]). Both come from the same + /// turn on the device, so another thread's picture can't put its counts in their place. + pub fn rgba_with_stats(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<(Vec, DrawStats)> { + self.draw(scene, None, cam, width, height) + } + + /// The picture and what it drew, both read while this call has the device to itself. + fn draw(&self, scene: &Scene, highlight: Option, cam: &Camera, width: u32, height: u32) -> Result<(Vec, DrawStats)> { if !(1..=self.max_side).contains(&width) || !(1..=self.max_side).contains(&height) { return Err(GpuError::Render(format!("a picture is 1 to {} pixels a side, not {width} × {height}", self.max_side))); } @@ -288,11 +299,7 @@ impl Capturer { device .poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(TIMEOUT) }) .map_err(|e| GpuError::Render(format!("waiting for the picture: {e}")))?; - match rx.try_recv() { - Ok(Ok(())) => {} - Ok(Err(e)) => return Err(GpuError::Render(format!("reading the picture back: {e}"))), - Err(_) => return Err(GpuError::Unsupported("reading a picture back without waiting for the browser".into())), - } + read_back(&rx)?; let data = slice.get_mapped_range().map_err(|e| GpuError::Render(format!("reading the picture back: {e}")))?; let mut rgba = Vec::with_capacity((row as usize) * (height as usize)); for r in data.chunks(padded as usize).take(height as usize) { @@ -301,13 +308,8 @@ impl Capturer { if rgba.len() != (row as usize) * (height as usize) { return Err(GpuError::Render("the picture read back short".into())); } - Ok(rgba) - } - - /// What the last picture drew ([`crate::view::DrawStats`]). - pub fn last_stats(&self) -> crate::view::DrawStats { - let Ok(state) = self.state.lock() else { return Default::default() }; - state.renderer.stats().lock().map(|s| *s).unwrap_or_default() + let stats = state.renderer.stats().lock().map(|s| *s).unwrap_or_default(); + Ok((rgba, stats)) } /// [`Capturer::rgba`] as PNG bytes. @@ -395,7 +397,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces clip: scene.clip.map_or([0.0; 4], |(n, d)| [n.x as f32, n.y as f32, n.z as f32, d as f32]), cap: [f32::from(cap.0) / 255.0, f32::from(cap.1) / 255.0, f32::from(cap.2) / 255.0, if scene.plain_cap { 0.0 } else { 1.0 }], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: analysis_uniform(mode, access.as_ref()), analysis_colors: scene.analysis.map_or([0.0; 36], |a| analysis_colors(&a.colors)), access, @@ -464,6 +466,19 @@ fn watch_lost(device: &wgpu::Device) -> Arc { lost } +/// The answer to a picture's read-back, once the wait for its submission is over. The read-back +/// is ready by then, but its answer may still be on its way: any other submit or poll on the +/// device (the window's frames, another picture's device check) can pick it up and answer it on +/// its own thread once it lets go of the device. So a native picture waits for the answer; a +/// browser answers later, on its own, and a picture can't wait for it there. +fn read_back(answer: &std::sync::mpsc::Receiver>) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + let answer = answer.recv_timeout(TIMEOUT).map_err(|e| GpuError::Render(format!("reading the picture back: {e}")))?; + #[cfg(target_arch = "wasm32")] + let answer = answer.try_recv().map_err(|_| GpuError::Unsupported("reading a picture back without waiting for the browser".into()))?; + answer.map_err(|e| GpuError::Render(format!("reading the picture back: {e}"))) +} + /// The background gradient: the viewport's top colour at the top, its bottom one at the bottom, /// mixed in sRGB as the window paints it. const BACKGROUND_SHADER: &str = r#" diff --git a/crates/gpu/src/path/path.wgsl b/crates/gpu/src/path/path.wgsl index 7c6aa05f5d..35e3f081dd 100644 --- a/crates/gpu/src/path/path.wgsl +++ b/crates/gpu/src/path/path.wgsl @@ -574,17 +574,22 @@ fn dominant_vertex(bary: vec3) -> u32 { if bary.z>bary[anchor] { anchor=2u; } return anchor; } +// The vertex after `a`, as (a+1)%3. Not written with `%`: NVIDIA's Vulkan compiler crashes on +// that index after a BVH walk (#6705). +fn next_vertex(a: u32) -> u32 { + return select(a+1u,0u,a>=2u); +} fn surface_point(t: Triangle, bary: vec3) -> vec3 { // Anchor near the hit, retaining the two other directly computed weights. // A tiny weight on a distant vertex can encode an entire corner clearance. // Do not recover that weight by subtracting the other two from one. let p=array,3>(t.p0.xyz,t.p1.xyz,t.p2.xyz); - let a=dominant_vertex(bary); let b=(a+1u)%3u; let c=(a+2u)%3u; + let a=dominant_vertex(bary); let b=next_vertex(a); let c=next_vertex(b); return p[a]+((p[b]-p[a])*bary[b]+(p[c]-p[a])*bary[c]); } fn surface_point_precise(t: Triangle, bary: vec3) -> PrecisePoint { let p=array,3>(t.p0.xyz,t.p1.xyz,t.p2.xyz); - let a=dominant_vertex(bary); let b=(a+1u)%3u; let c=(a+2u)%3u; + let a=dominant_vertex(bary); let b=next_vertex(a); let c=next_vertex(b); // Keep the world anchor separate from its local displacement. return PrecisePoint(p[a],(p[b]-p[a])*bary[b]+(p[c]-p[a])*bary[c]); } @@ -595,7 +600,7 @@ fn surface_ray_depth_precise(t: Triangle, bary: vec3, origin: vec3, or } fn surface_ray_depth(t: Triangle, bary: vec3, origin: vec3, axis: u32) -> f32 { let p=array,3>(t.p0.xyz,t.p1.xyz,t.p2.xyz); - let a=dominant_vertex(bary); let b=(a+1u)%3u; let c=(a+2u)%3u; + let a=dominant_vertex(bary); let b=next_vertex(a); let c=next_vertex(b); // Original vertex differences avoid subtracting two rounded ray-relative // coordinates; adding the anchor last preserves distant thin-hit ordering. return (p[a][axis]-origin[axis])+((p[b][axis]-p[a][axis])*bary[b]+(p[c][axis]-p[a][axis])*bary[c]); @@ -660,16 +665,6 @@ fn area_pdf(light: AreaLight, delta: vec3) -> f32 { if cosine<=0.0 { return 0.0; } return dot(delta,delta)/(light.center.w*cosine); } -fn visible(o: vec3, d: vec3, distance: f32) -> ShadowResult { - if emitter_hit(o,d).distance0.0 { - let attenuation=volume_transmittance(o,d,distance); - visibility.transmittance*=attenuation; - visibility.branch_pdf*=attenuation; - } - return visibility; -} struct EnvironmentSample { direction: vec3, radiance: vec3, pdf: f32 }; fn environment_to_world(d: vec3) -> vec3 { return vec3(u.environment_map.x*d.x-u.environment_map.y*d.y,u.environment_map.y*d.x+u.environment_map.x*d.y,d.z); @@ -723,43 +718,67 @@ fn sun_sample(rng:ptr)->EnvironmentSample { let direction=from_local(vec3(radius*cos(phi),radius*sin(phi),z),u.sun_direction.xyz); return EnvironmentSample(direction,u.sun_radiance.xyz,u.sun_radiance.w); } -fn direct_studio(t: Triangle, position: vec3, o: vec3, n: vec3, geometric: vec3, v: vec3, remaining: u32, rng: ptr) -> vec3 { +// The three fixed lights of the preview without studio lighting: direction and intensity. +const FIXED_LIGHTS = array,3>(vec4(0.6,0.0,0.8,3.0),vec4(-0.8,0.0,0.6,1.0),vec4(0.0,-0.6,0.8,1.5)); +// Next-event estimation for every light source family through ONE shadow-ray call site, like +// volume_direct. Native compilers inline each call of the BVH walk; one walk per source family +// made `main` too large for NVIDIA's Vulkan compiler (#6705). Studio lighting visits every area +// light, then the environment, then the sun, drawing random numbers in that order; without it +// the three fixed lights are used. +fn direct_light(t: Triangle, position: vec3, o: vec3, n: vec3, geometric: vec3, v: vec3, remaining: u32, rng: ptr) -> vec3 { var result = vec3(0.0); let opaque = 1.0-t.base.w; - if opaque<=0.0 { return result; } - for (var i=0u; i=remaining); - result += visibility.transmittance*brdf(t,n,v,direction)*light.radiance.xyz*(opaque*nl*weight/pdf); - } - if u.environment_map.w>0.0 || any(u.environment.xyz>vec3(0.0)) { - let sample=env_sample(rng); - let nl=dot(n,sample.direction); - if sample.pdf>0.0 && nl>0.0 && dot(geometric,sample.direction)>0.0 { - let visibility=visible(o,sample.direction,FAR); - let competing=opaque*surface_pdf(t,n,v,sample.direction)*visibility.branch_pdf; - let weight=select(power_weight(sample.pdf,competing),1.0,visibility.solid || visibility.crossings>=remaining); - result+=visibility.transmittance*brdf(t,n,v,sample.direction)*sample.radiance*(opaque*nl*weight/sample.pdf); + let studio = u.environment.w > 0.0; + if studio && opaque<=0.0 { return result; } + let area_count = u32(u.light_count.w); + let count = select(3u, area_count+2u, studio); + for (var source=0u; source(0.0,0.0,1.0),vec3(0.0),0.0); + var shadow_direction = vec3(0.0,0.0,1.0); + var shadow_distance = FAR; + if !studio { + let light = FIXED_LIGHTS[source]; + if u.lighting.z <= 0.0 || dot(geometric,light.xyz)<=0.0 || dot(n,light.xyz)<=0.0 { continue; } + sample = EnvironmentSample(light.xyz,vec3(light.w*u.lighting.z*u.studio_weights[source]),1.0); + shadow_direction = light.xyz; + } else { + if sourcevec3(0.0)) { continue; } + sample = env_sample(rng); + shadow_direction = sample.direction; + } else { + if u.sun_radiance.w<=0.0 { continue; } + sample = sun_sample(rng); + shadow_direction = sample.direction; + } + if sample.pdf<=0.0 || dot(n,sample.direction)<=0.0 || dot(geometric,sample.direction)<=0.0 { continue; } } - } - if u.sun_radiance.w>0.0 { - let sample=sun_sample(rng); - let nl=dot(n,sample.direction); - if nl>0.0 && dot(geometric,sample.direction)>0.0 { - let visibility=visible(o,sample.direction,FAR); - let competing=opaque*surface_pdf(t,n,v,sample.direction)*visibility.branch_pdf; - let weight=select(power_weight(sample.pdf,competing),1.0,visibility.solid || visibility.crossings>=remaining); - result+=visibility.transmittance*brdf(t,n,v,sample.direction)*sample.radiance*(opaque*nl*weight/sample.pdf); + // Studio emitters block a connection that passes them; studio volumes attenuate it. + var visibility = blocked_shadow(); + if !studio || emitter_hit(o,shadow_direction).distance>=shadow_distance-u.lighting.x*2.0 { + visibility = shadow_transmittance(o,shadow_direction,shadow_distance); + if studio && u.volume_min.w>0.0 { + let attenuation=volume_transmittance(o,shadow_direction,shadow_distance); + visibility.transmittance*=attenuation; + visibility.branch_pdf*=attenuation; + } } + let nl = dot(n,sample.direction); + if !studio { + result += visibility.transmittance * brdf(t,n,v,sample.direction) * (opaque*nl*sample.radiance); + continue; + } + let competing=opaque*surface_pdf(t,n,v,sample.direction)*visibility.branch_pdf; + let weight=select(power_weight(sample.pdf,competing),1.0,visibility.solid || visibility.crossings>=remaining); + result += visibility.transmittance*brdf(t,n,v,sample.direction)*sample.radiance*(opaque*nl*weight/sample.pdf); } return result; } @@ -816,7 +835,6 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4 { var previous_delta = true; var media: array; var medium_count = 0u; - let lights = array,3>(vec4(0.6,0.0,0.8,3.0),vec4(-0.8,0.0,0.6,1.0),vec4(0.0,-0.6,0.8,1.5)); for (var bounce = 0u; bounce < u.sampling.y; bounce += 1u) { // Closed-glass continuations set an excluded producing triangle; every // other launch (camera, opaque or thin-sheet surface) does not. @@ -947,16 +965,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4 { } previous_position = position; previous_position_low=position_low; o = position + geometric*(u.lighting.x*2.0); o_low=vec3(0.0); - if u.environment.w > 0.0 { - radiance += throughput*direct_studio(t,position,o,n,geometric,v,u.sampling.y-bounce-1u,&rng); - } - for (var i=0u; i<3u && u.environment.w==0.0; i+=1u) { - let light = lights[i]; - if u.lighting.z > 0.0 && dot(geometric,light.xyz)>0.0 && dot(n,light.xyz)>0.0 { - let visibility=shadow_transmittance(o,light.xyz,FAR); - radiance += visibility.transmittance * throughput * brdf(t,n,v,light.xyz) * ((1.0-t.base.w)*light.w*dot(n,light.xyz)*u.lighting.z*u.studio_weights[i]); - } - } + radiance += throughput*direct_light(t,position,o,n,geometric,v,u.sampling.y-bounce-1u,&rng); if t.base.w > 0.0 && random(&rng) < t.base.w { var nt = t.parameters.z; if t.parameters.w < 0.0 && entering && medium_count > 0u && u.lighting.w==0.0 { @@ -1105,14 +1114,22 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let pilots = u.adaptive_budget.x; let strata = max(1u,u.adaptive_budget.z); let lane = u32(u.adaptive_error.w); + // Settle first whether this invocation samples at all, then take the path sample at one call + // site: every call is inlined, and three copies of the integrator were more than NVIDIA's + // Vulkan compiler could build (#6705). + var budget_count = 0u; + if pilots > 0u && u.image.z >= pilots { + budget_count = u32(accumulation[2u*pixels+pixel].x); + if u.image.z-pilots >= budget_count { return; } + } + let value = sample_path(pixel); if pilots == 0u { - let sample = sample_path(pixel)/f32(strata); + let sample = value/f32(strata); if u.image.z == 0u && lane == 0u { accumulation[pixel] = sample; } else { accumulation[pixel] += sample; } return; } if u.image.z < pilots { - let value = sample_path(pixel); var mean = accumulation[pixels+pixel]; var m2 = accumulation[2u*pixels+pixel]; if u.image.z == 0u { mean=vec4(0.0); m2=vec4(0.0); } @@ -1127,10 +1144,8 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } return; } - let budget_count=u32(accumulation[2u*pixels+pixel].x); let ordinal=u.image.z-pilots; - if ordinal>=budget_count { return; } - accumulation[pixel] += sample_path(pixel)/f32(strata); + accumulation[pixel] += value/f32(strata); // Per-pixel production means enter both raw EXR and denoising. Pilots do not. if ordinal+1u == budget_count && lane+1u == strata { accumulation[pixel] /= f32(budget_count); } } diff --git a/crates/gpu/src/path/tests.rs b/crates/gpu/src/path/tests.rs index ed40cae2fb..24e838ffca 100644 --- a/crates/gpu/src/path/tests.rs +++ b/crates/gpu/src/path/tests.rs @@ -531,6 +531,48 @@ fn shader_validates_without_a_device() { naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap(); } +/// #6705: native compilers inline every call. With 33 inlined BVH walks in `main` (three path +/// samples, a shadow walk per light source family) NVIDIA's Vulkan compiler crashed or ran out +/// of memory and never built the pipeline. Count the walks `main` inlines, so another call site +/// is a deliberate choice, checked on such a driver. +#[test] +fn main_inlines_few_bvh_walks() { + fn callees(block: &naga::Block, out: &mut Vec>) { + for statement in block.iter() { + match statement { + naga::Statement::Call { function, .. } => out.push(*function), + naga::Statement::Block(inner) => callees(inner, out), + naga::Statement::If { accept, reject, .. } => { + callees(accept, out); + callees(reject, out); + } + naga::Statement::Switch { cases, .. } => cases.iter().for_each(|case| callees(&case.body, out)), + naga::Statement::Loop { body, continuing, .. } => { + callees(body, out); + callees(continuing, out); + } + _ => {} + } + } + } + fn walks(module: &naga::Module, function: &naga::Function) -> usize { + let mut called = Vec::new(); + callees(&function.body, &mut called); + called + .into_iter() + .map(|handle| { + let callee = &module.functions[handle]; + let walk = matches!(callee.name.as_deref(), Some("trace_precise_within" | "trace_ordered_within")); + usize::from(walk) + walks(module, callee) + }) + .sum() + } + let module = naga::front::wgsl::parse_str(SHADER).unwrap(); + let main = module.entry_points.iter().find(|entry| entry.name == "main").unwrap(); + let inlined = walks(&module, &main.function); + assert!(inlined <= 5, "`main` inlines {inlined} BVH walks"); +} + #[test] fn render_lights_path_visibility_uniform_keeps_the_shader_contract() { shader_validates_without_a_device(); diff --git a/crates/gpu/src/select.rs b/crates/gpu/src/select.rs index 9d40665150..388f189c3d 100644 --- a/crates/gpu/src/select.rs +++ b/crates/gpu/src/select.rs @@ -232,7 +232,15 @@ pub fn pick_path(policy: Policy, keep: impl Fn(&wgpu::AdapterInfo) -> bool) -> O /// The adapter `policy` picks, with its report; `None` when no adapter qualifies. #[cfg(not(target_arch = "wasm32"))] pub fn pick(policy: Policy) -> Option<(wgpu::Adapter, AdapterReport)> { - adapters(&instance()) + pick_among(adapters(&instance()), policy) +} + +/// The adapter `policy` picks among `found`, as [`pick`] picks among the native backends' (best +/// first, the first whose tier the policy admits), with its report; `None` when none qualifies. +/// For a caller with an instance of its own, such as the UI's offscreen tests. +pub fn pick_among(mut found: Vec, policy: Policy) -> Option<(wgpu::Adapter, AdapterReport)> { + found.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type))); + found .into_iter() .map(|a| { let r = report(&a.get_info()); diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs index edfbf4cdb3..e005349788 100644 --- a/crates/gpu/src/view.rs +++ b/crates/gpu/src/view.rs @@ -17,6 +17,8 @@ use crate::pbr; use wgpu::util::DeviceExt; mod ao; +mod clouds; +pub use clouds::{CloudFilter, CloudStyle, ColorMode, DOT_SIZE, Dot, FILTERS, GpuCloud, cloud_dot}; #[cfg(not(target_arch = "wasm32"))] mod msaa_check; mod oit; @@ -167,7 +169,7 @@ pub struct PlannedBody { /// What the viewport's last frame drew, counted as it was drawn (the performance overlay, #2548). /// Triangles and line segments count every pass that draws them, so a section's stencil passes -/// count twice; that is the work the GPU did. +/// count once each; that is the work the GPU did. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct DrawStats { pub draw_calls: u32, @@ -921,34 +923,6 @@ pub struct GpuImage { /// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32). const IMG_SIZE: usize = 24; -/// A point cloud: round dots of a size on screen, depth-tested and written like the model's -/// faces. -#[derive(Clone)] -pub struct GpuPoints { - pub id: u64, - /// Changes when the points change (they are uploaded again). - pub version: u64, - /// [`CLOUD_POINT_SIZE`] bytes per point: position (3 × f32, in the design), colour (RGBA8, - /// the alpha the dot's size in eighths of a pixel, see [`cloud_point`]). - pub bytes: Arc>, -} - -/// Bytes per point of a [`GpuPoints`]. -pub const CLOUD_POINT_SIZE: usize = 16; - -/// A point of a [`GpuPoints`]: where it is, its colour and its size on screen (pixels, up to 31). -pub fn cloud_point(out: &mut Vec, p: [f32; 3], rgb: [u8; 3], size: f32) { - for v in p { - out.extend_from_slice(&v.to_le_bytes()); - } - out.extend_from_slice(&rgb); - out.push((size * 8.0).round().clamp(8.0, 255.0) as u8); -} - -fn point_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Unorm8x4]; - wgpu::VertexBufferLayout { array_stride: CLOUD_POINT_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } -} /// Ambient occlusion samples per shaded pixel in a still picture (the shader's most). pub const AO_SAMPLES: u32 = 32; @@ -990,7 +964,7 @@ pub struct Frame { /// Canvases and decals. pub images: Vec, /// Point clouds. - pub points: Vec, + pub clouds: Vec, /// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment, /// 5 accessibility; parameter; scale; size) and the pull / access direction. pub analysis: [f32; 8], @@ -1965,12 +1939,21 @@ impl Batches { } } -/// The section's pipelines (see `Renderer::tri_cut`). +/// The section's pipelines (see `Renderer::tri_cut`). The stencil's low bits ([`WINDING`]) count +/// the faces behind the cut on each pixel, its high bit ([`NOT_CAP`]) marks where no cap shows. struct Cut { - /// Draws the faces and marks the stencil by their true depth: 1 where a back face (the cap) + /// No colour or depth: counts the faces behind the cut, +1 per back face and -1 per front + /// face, so the count is not 0 where the cut runs through a body, whatever the faces' order. + wind: wgpu::RenderPipeline, + /// Draws the faces and marks the stencil by their true depth: [`NOT_CAP`] where a front face /// is the visible surface. Unpushed: `FACE_BIAS`'s slope part would let a front face win a /// band along its edge with a back face seen through the cut, and the cap would skip it (#6328). mark: wgpu::RenderPipeline, + /// Where the cut runs through a body but a front face won the marking: the back faces again, + /// pulled toward the eye (`fs_solid_touch`), and marked as the cap where one wins. That front + /// face is another body's lying on the far wall (a shaft in its bore), faceted another way, + /// which won in streaks (#9407). + touch: wgpu::RenderPipeline, /// The faces' depth again, pushed back by `FACE_BIAS` as unsectioned faces are (after /// `Renderer::section_reset`), so their edges draw over them (#1434). No colour, no stencil. push: wgpu::RenderPipeline, @@ -1979,6 +1962,11 @@ struct Cut { cap: wgpu::RenderPipeline, } +/// The stencil bits of [`Cut::wind`]'s count (kept modulo 128). +const WINDING: u32 = 0x7f; +/// The stencil bit [`Cut::mark`] sets where a front face is the visible surface (no cap there). +const NOT_CAP: u32 = 0x80; + /// The viewport's pipelines and the scenes uploaded to one device, for one kind of target. pub struct Renderer { /// What the last frame drew ([`Renderer::stats`]). @@ -2060,10 +2048,8 @@ pub struct Renderer { textures: std::collections::HashMap, /// This frame's quads: (image id, vertices). quads: Vec<(u64, Batch)>, - /// Point clouds' dots. - point: wgpu::RenderPipeline, - /// Point clouds by id: (version, points). - clouds: std::collections::HashMap, + cloud_pipes: clouds::CloudPipes, + clouds: clouds::CloudDraws, access_bgl: wgpu::BindGroupLayout, /// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version. access_bind: wgpu::BindGroup, @@ -2114,6 +2100,9 @@ pub struct Renderer { /// The scene target's depth back to the far plane between the section's marking and push /// passes ([`Cut`]); stencil and colour untouched. section_reset: Option, + /// The scene target's stencil to 0 before the section's passes count faces in it ([`Cut::wind`]); + /// depth and colour untouched. + section_clear: Option, /// The new resolve textures [`clear_resolve_targets`] has cleared (tests count them). #[cfg(test)] resolve_clears: u32, @@ -2692,31 +2681,54 @@ impl Renderer { // While a section is shown, the depth states of the stencil marking and of the cap (see // `Renderer::tri_cut`), where the depth buffer has a stencil. let cut_states = t.depth.filter(|f| f.has_stencil_aspect()).map(|format| { - // The visible surface, by its true depth, marks the stencil: 1 where it is a back face (the cap). - use wgpu::StencilOperation::{Keep, Replace, Zero}; + use wgpu::StencilOperation::{DecrementWrap, IncrementWrap, Keep, Replace, Zero}; let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op }; + // First the faces behind the cut are counted, back +1 and front -1, whatever their depth. + let wind = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState { front: face(DecrementWrap), back: face(IncrementWrap), read_mask: 0xff, write_mask: WINDING }, + bias: Default::default(), + }; + // The visible surface, by its true depth, marks the stencil: `NOT_CAP` where it is a + // front face (the reference), cleared where it is a back face (the cap). let mark = wgpu::DepthStencilState { format, depth_write_enabled: Some(true), depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::Less)), - stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, + stencil: wgpu::StencilState { front: face(Replace), back: face(Zero), read_mask: 0xff, write_mask: NOT_CAP }, bias: Default::default(), }; - // Then the cap's back faces again, only there, at the section plane's depth. + // Where a front face won but the count says the cut runs through a body (`NOT_CAP` set + // and a count: above the reference `NOT_CAP`), a back face pulled in front of it is the + // cap after all. + let through = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Less, ..face(Zero) }; + let touch = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::LessEqual)), + stencil: wgpu::StencilState { front: through, back: through, read_mask: 0xff, write_mask: NOT_CAP }, + bias: Default::default(), + }; + // Then the cap's back faces again, only there (`NOT_CAP` clear, the reference 0), at the + // section plane's depth. let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; let cap = wgpu::DepthStencilState { format, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Always), - stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 }, + stencil: wgpu::StencilState { front: only, back: only, read_mask: NOT_CAP, write_mask: 0 }, bias: Default::default(), }; - (mark, cap) + (wind, mark, touch, cap) }); - // `Cut::mark` and `Cut::push` use `tri`'s fragment shader; `Cut::cap` has its own. + // `Cut::wind`, `Cut::mark` and `Cut::push` use `tri`'s fragment shader; `Cut::touch` and + // `Cut::cap` have their own. let marks = |source: TriSource| { - cut_states.as_ref().map(|(mark, _)| { + cut_states.as_ref().map(|(wind, mark, _, _)| { ( + solid_writes("sc_tris_wind", "fs_solid", None, Some(wind.clone()), source, false), solid("sc_tris_mark", "fs_solid", None, Some(mark.clone()), source), solid_writes("sc_tris_push", "fs_solid", None, pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), source, false), ) @@ -2727,10 +2739,16 @@ impl Renderer { // outlines showed through it, #6328). The marking pass already drew the nearest back // face's colour. let cap = |source: TriSource| { - cut_states.as_ref().map(|(_, cap)| solid_writes("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap.clone()), source, false)) + cut_states.as_ref().map(|(_, _, touch, cap)| { + ( + solid("sc_tris_touch", "fs_solid_touch", Some(wgpu::Face::Front), Some(touch.clone()), source), + solid_writes("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap.clone()), source, false), + ) + }) }; - let cut = |marks: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, cap: Option| { - marks.zip(cap).map(|((mark, push), cap)| Cut { mark, push, cap }) + type Marks = (wgpu::RenderPipeline, wgpu::RenderPipeline, wgpu::RenderPipeline); + let cut = |marks: Option, cap: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>| { + marks.zip(cap).map(|((wind, mark, push), (touch, cap))| Cut { wind, mark, touch, push, cap }) }; let access_bind = access_texture(device, None, &access_bgl, 1, &[]); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -2805,6 +2823,7 @@ impl Renderer { ( window.reversed_depth().then(|| depth_reset_pipeline(device, window)).flatten(), t.depth.filter(|f| f.has_stencil_aspect()).and_then(|_| depth_reset_pipeline(device, t)), + t.depth.filter(|f| f.has_stencil_aspect()).and_then(|_| stencil_clear_pipeline(device, t)), ) }; let opaque = || pushed(wgpu::CompareFunction::Less, true, FACE_BIAS); @@ -2875,7 +2894,7 @@ impl Renderer { pipeline("sc_guide_lines", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), ) }; - let point = || pipeline("sc_points", "vs_point", "fs_point", point_layout(), depth(wgpu::CompareFunction::Less, true), None); + let cloud_pipes = || clouds::CloudPipes::new(device, &module, &bgl, t, depth(wgpu::CompareFunction::Less, true)); let guide_trans = || pipeline("sc_guide_trans", "vs_tri", "fs_tri_all", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha); // Each job on a thread of its own where the device allows it: D3D12 compiles every shader // as its pipeline is made, which took seconds one after another (#4860). @@ -2892,7 +2911,7 @@ impl Renderer { let lines = jobs.spawn(lines); let lines_all = jobs.spawn(lines_all); let guide_trans = jobs.spawn(guide_trans); - let point = jobs.spawn(point); + let cloud_pipes = jobs.spawn(cloud_pipes); let image = jobs.spawn(image); let ao = jobs.spawn(ao); let oit = jobs.spawn(oit); @@ -2909,12 +2928,13 @@ impl Renderer { let (line, line_hidden) = lines.join(); let (overlay, guide_line) = lines_all.join(); let (ss_blit, copy_blit, ss_filter) = blits.join(); - let (depth_reset, section_reset) = resets.join(); + let (depth_reset, section_reset, section_clear) = resets.join(); let oit = oit.join(); Renderer { ao: ao.join(), depth_reset, section_reset, + section_clear, #[cfg(test)] resolve_clears: 0, target: GpuTarget { oit: oit.is_some(), ..t }, @@ -2941,7 +2961,7 @@ impl Renderer { sampler, textures: Default::default(), quads: Vec::new(), - point: point.join(), + cloud_pipes: cloud_pipes.join(), clouds: Default::default(), tri, tri_cut: cut(tri_marks, tri_cap), @@ -3036,24 +3056,47 @@ const GHOST_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: 2 * FA /// A pipeline that sets the depth of the viewport in a pass of `t`'s kind back to the far plane, /// leaving colour and stencil as they are. None without a depth buffer. fn depth_reset_pipeline(device: &wgpu::Device, t: GpuTarget) -> Option { + viewport_fill_pipeline(device, t, "sc_depth_reset", true, Default::default()) +} + +/// A pipeline that sets the stencil of the viewport in a pass of `t`'s kind to 0, leaving colour +/// and depth as they are. None without a depth buffer. +fn stencil_clear_pipeline(device: &wgpu::Device, t: GpuTarget) -> Option { + let zero = wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Always, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Zero, + }; + viewport_fill_pipeline(device, t, "sc_stencil_clear", false, wgpu::StencilState { front: zero, back: zero, read_mask: 0xff, write_mask: 0xff }) +} + +/// A triangle over the whole viewport in a pass of `t`'s kind, writing no colour: the far plane's +/// depth where `depth`, and `stencil`'s operations. +fn viewport_fill_pipeline( + device: &wgpu::Device, + t: GpuTarget, + label: &str, + depth: bool, + stencil: wgpu::StencilState, +) -> Option { let format = t.depth?; let far = if t.reversed_depth() { "0.0" } else { "1.0" }; let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("sc_depth_reset"), + label: Some(label), source: wgpu::ShaderSource::Wgsl(DEPTH_RESET_SHADER.replace("FAR", far).into()), }); - let layout = - device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("sc_depth_reset"), bind_group_layouts: &[], immediate_size: 0 }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some(label), bind_group_layouts: &[], immediate_size: 0 }); Some(device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_depth_reset"), + label: Some(label), layout: Some(&layout), vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { format, - depth_write_enabled: Some(true), + depth_write_enabled: Some(depth), depth_compare: Some(wgpu::CompareFunction::Always), - stencil: Default::default(), + stencil, bias: Default::default(), }), multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, @@ -3277,10 +3320,7 @@ fn frame_key(cb: &Frame) -> u64 { img.corners.iter().flatten().for_each(|v| v.to_bits().hash(&mut h)); img.opacity.to_bits().hash(&mut h); } - for cloud in &cb.points { - cloud.id.hash(&mut h); - cloud.version.hash(&mut h); - } + clouds::hash_clouds(&cb.clouds, &mut h); match &cb.access { Some(a) => { true.hash(&mut h); @@ -3430,20 +3470,7 @@ impl Renderer { res.quads.push((img.id, b)); } } - // Point clouds: upload new or changed ones, drop removed ones. - res.clouds.retain(|id, _| cb.points.iter().any(|c| c.id == *id)); - for c in &cb.points { - if res.clouds.get(&c.id).is_none_or(|(v, _)| *v != c.version) { - match upload(device, "sc_points", &c.bytes, CLOUD_POINT_SIZE) { - Some(b) => { - res.clouds.insert(c.id, (c.version, b)); - } - None => { - res.clouds.remove(&c.id); - } - } - } - } + res.clouds.prepare(device, queue, &res.cloud_pipes, &cb.clouds); res.ss_ready = (cb.ssaa != 1.0 || see_through || res.window_differs()) && res.supersample(device, encoder, cb, see_through); res.ss_key = res.ss_ready.then_some(key); } @@ -3638,9 +3665,9 @@ impl Renderer { res.tri_cut_planned.as_ref(), res.tri_cut_instanced.as_ref(), res.tri_cut_instanced_flat.as_ref(), - res.section_reset.as_ref(), + res.section_reset.as_ref().zip(res.section_clear.as_ref()), ) { - (Some(cut), Some(cut_planned), Some(cut_instanced), Some(cut_flat), Some(reset)) => { + (Some(cut), Some(cut_planned), Some(cut_instanced), Some(cut_flat), Some((reset, clear))) => { // The model's own triangles, blocks, plans and instances with its palette, then the // preview's with its own. let all = |pass: &mut wgpu::RenderPass<'static>, pick: fn(&Cut) -> &wgpu::RenderPipeline| { @@ -3652,12 +3679,20 @@ impl Renderer { pass.set_bind_group(0, scene_bind(pv, &res.bind), &[]); tris(pass, pick(cut), &pv.tris); }; - pass.set_stencil_reference(1); + // The stencil starts at 0 (the window's pass keeps it from frame to frame), counts + // the faces behind the cut, then marks where no cap shows (`Cut`). + pass.set_pipeline(clear); + pass.draw(0..3, 0..1); + tally.call(); + all(pass, |c| &c.wind); + pass.set_stencil_reference(NOT_CAP); all(pass, |c| &c.mark); + all(pass, |c| &c.touch); pass.set_pipeline(reset); pass.draw(0..3, 0..1); tally.call(); all(pass, |c| &c.push); + pass.set_stencil_reference(0); all(pass, |c| &c.cap); } _ => { @@ -3673,14 +3708,7 @@ impl Renderer { pass.set_bind_group(0, scene_bind(m, &res.bind), &[]); tris(pass, &res.pbr, &m.pbr); if !cb.product { - for (_, b) in res.clouds.values() { - pass.set_pipeline(&res.point); - for (buffer, count) in &b.parts { - pass.set_vertex_buffer(0, buffer.slice(..)); - pass.draw(0..6, 0..*count); - tally.triangles(6, *count); - } - } + res.clouds.paint(pass, &res.cloud_pipes, tally); } // Whole-body tints first, so a face or an edge highlighted over the body (in `h.tris` // and `h.lines`) keeps its own colour, as it did when the body's triangles were built @@ -4377,7 +4405,7 @@ mod tests { clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: [0.0; 36], access: None, @@ -4764,7 +4792,7 @@ mod glass_tests { clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: [0.0; 36], access: None, @@ -4939,7 +4967,7 @@ mod glass_tests { clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: [0.0; 36], access: None, @@ -4964,28 +4992,39 @@ mod glass_tests { } #[test] - fn a_cached_point_cloud_picture_updates_after_recoloring_and_hiding() { + fn a_cached_cloud_picture_follows_its_style_without_uploading_again() { let Some((_, device, queue)) = crate::open_headless().ok() else { return }; let depth = crate::depth_format(&device, true); let format = wgpu::TextureFormat::Rgba8Unorm; let mut renderer = Renderer::new(&device, GpuTarget { format, depth: Some(depth), samples: 1, tess: false, oit: false }, None); let mut frame = test_frame(GpuScene::default(), 2.0); - let mut bytes = Vec::new(); - cloud_point(&mut bytes, [0.0; 3], [255, 0, 0], 20.0); - frame.points.push(GpuPoints { id: 1, version: 1, bytes: Arc::new(bytes) }); - let red = render_frame(&mut renderer, &device, &queue, &frame, true); - assert!(red.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 30 && p[2] < 30), "the cloud is drawn"); - render_frame(&mut renderer, &device, &queue, &frame, true); - assert!(renderer.cached.is_some()); - let mut bytes = Vec::new(); - cloud_point(&mut bytes, [0.0; 3], [0, 0, 255], 20.0); - frame.points = vec![GpuPoints { id: 1, version: 2, bytes: Arc::new(bytes) }]; - let blue = render_frame(&mut renderer, &device, &queue, &frame, true); - assert!(blue.as_chunks::<4>().0.iter().any(|p| p[0] < 30 && p[1] < 30 && p[2] > 200), "the edited cloud replaces the cached picture"); - assert_ne!(red, blue); - frame.points.clear(); + // A dot labelled 2 (red in the palette) and a dot filtered by 5 (a range) and the label + // 2 (a set). + let mut dots = Vec::new(); + cloud_dot(&mut dots, &Dot { at: [5.0, 5.0, 0.0], rgb: [0, 0, 255], value: 2.0, has: 16, ..Default::default() }); + let filtered = Dot { at: [-3.0, -3.0, 0.0], value: 1.0, filtered: [5.0, 2.0, 0.0, 0.0], rgb: [0, 255, 0], has: 0b10011 }; + cloud_dot(&mut dots, &filtered); + let style = CloudStyle { mode: ColorMode::Own, range: [0.0, 1.0], filters: Default::default(), point_size: 20.0 }; + frame.clouds.push(GpuCloud { id: 1, version: 1, dots: Arc::new(dots), style }); + let has = |img: &[u8], f: &dyn Fn(&[u8; 4]) -> bool| img.as_chunks::<4>().0.iter().any(f); + let green = |p: &[u8; 4]| p[1] > 120 && p[0] < 30 && p[2] < 30; + let own = render_frame(&mut renderer, &device, &queue, &frame, true); + assert!(has(&own, &|p| p[2] > 200 && p[0] < 30), "a dot in its own colour"); + assert!(has(&own, &green), "the other in its own colour"); + // Values from -1: bit 3 is the label 2, bit 2 the label 1. + frame.clouds[0].style.filters[1] = CloudFilter::Values { offset: -1, mask: 0b1000 }; + assert!(has(&render_frame(&mut renderer, &device, &queue, &frame, true), &green), "label 2 kept"); + frame.clouds[0].style.filters[1] = CloudFilter::Values { offset: -1, mask: 0b0100 }; + assert!(!has(&render_frame(&mut renderer, &device, &queue, &frame, true), &green), "label 2 left out"); + frame.clouds[0].style.filters[1] = CloudFilter::Off; + frame.clouds[0].style.filters[0] = CloudFilter::Range([0.0, 4.0]); + frame.clouds[0].style.mode = ColorMode::Palette; + let filtered = render_frame(&mut renderer, &device, &queue, &frame, true); + assert!(!has(&filtered, &green), "its 5 lies outside the range"); + assert!(has(&filtered, &|p| p[0] > 200 && p[1] < 90 && p[2] < 90), "the first, without that value, in the palette's red"); + frame.clouds.clear(); let hidden = render_frame(&mut renderer, &device, &queue, &frame, true); - assert!(!hidden.as_chunks::<4>().0.iter().any(|p| p[0] < 30 && p[1] < 30 && p[2] > 200), "hiding the cloud removes its dots"); + assert!(!has(&hidden, &|p| p[0] > 200 && p[1] < 90 && p[2] < 90), "hiding the cloud removes it"); } /// A still view shows the picture it drew last: a reused frame (whose filtered picture is diff --git a/crates/gpu/src/view/clouds.rs b/crates/gpu/src/view/clouds.rs new file mode 100644 index 0000000000..52c0f05a76 --- /dev/null +++ b/crates/gpu/src/view/clouds.rs @@ -0,0 +1,227 @@ +//! Point clouds: each point a round dot a few pixels across, facing the screen, depth-tested and +//! written like the model's faces. Every point is uploaded once, with the values it is coloured +//! and filtered by; how they are coloured and which are shown is a [`CloudStyle`] of a few +//! numbers the shaders read, so a new colour range or filter range draws again at once without +//! uploading anything. + +use std::sync::Arc; + +use super::{Batch, Tally, upload}; + +/// A point cloud as the GPU draws it. +#[derive(Clone)] +pub struct GpuCloud { + pub id: u64, + /// Changes when the records change (they are uploaded again). + pub version: u64, + /// [`DOT_SIZE`] bytes per point, written by [`cloud_dot`]. + pub dots: Arc>, + pub style: CloudStyle, +} + +/// How a cloud is coloured and which of its points are shown. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CloudStyle { + pub mode: ColorMode, + /// The ramp's range of values. + pub range: [f32; 2], + /// A point is shown where every filter keeps its filtered value. + pub filters: [CloudFilter; FILTERS], + /// A dot's size on screen, in pixels. + pub point_size: f32, +} + +/// Filters the GPU applies at once. +pub const FILTERS: usize = 4; + +/// The values a filter keeps. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum CloudFilter { + /// All of them. + #[default] + Off, + /// Those within a range, ends included. + Range([f32; 2]), + /// Whole values `offset + i` for each bit `i` set in `mask`. + Values { offset: i32, mask: u32 }, +} + +/// Where a point's colour comes from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorMode { + /// Its own colour ([`Dot::rgb`]). + #[default] + Own, + /// The ramp over [`CloudStyle::range`] at its value. + Ramp, + /// The palette at its whole value. + Palette, +} + +/// One point: where it is, the coloured value and the four filtered values, its own colour, and +/// which of the four filters and the value it has (bits 0 to 3, and 4, of `has`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Dot { + pub at: [f32; 3], + pub value: f32, + pub filtered: [f32; FILTERS], + pub rgb: [u8; 3], + pub has: u8, +} + +/// Bytes per [`Dot`]. +pub const DOT_SIZE: usize = 36; + +/// Append a dot's record. +pub fn cloud_dot(out: &mut Vec, d: &Dot) { + for v in d.at.iter().chain([&d.value]).chain(&d.filtered) { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&d.rgb); + out.push(d.has); +} + +fn dot_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32, 2 => Float32x4, 3 => Unorm8x4]; + wgpu::VertexBufferLayout { array_stride: DOT_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } +} + +/// The uniform a cloud's style is written to, bytes: the colouring, then a `vec4` per filter. +const STYLE_SIZE: u64 = 16 + 16 * FILTERS as u64; + +fn style_bytes(s: &CloudStyle) -> [u8; STYLE_SIZE as usize] { + let mode = match s.mode { + ColorMode::Own => 0.0f32, + ColorMode::Ramp => 1.0, + ColorMode::Palette => 2.0, + }; + let mut words = vec![mode.to_bits(), s.point_size.to_bits(), s.range[0].to_bits(), s.range[1].to_bits()]; + for f in &s.filters { + // Kind, then a range's ends, or the first value and the mask of those kept. + words.extend(match *f { + CloudFilter::Off => [0.0f32.to_bits(), 0, 0, 0], + CloudFilter::Range([lo, hi]) => [1.0f32.to_bits(), lo.to_bits(), hi.to_bits(), 0], + CloudFilter::Values { offset, mask } => [2.0f32.to_bits(), (offset as f32).to_bits(), 0, mask], + }); + } + let mut out = [0u8; STYLE_SIZE as usize]; + for (o, w) in out.as_chunks_mut::<4>().0.iter_mut().zip(words) { + *o = w.to_le_bytes(); + } + out +} + +/// The pipeline that draws clouds' dots, and the layout of their styles. +pub(super) struct CloudPipes { + layout: wgpu::BindGroupLayout, + dots: wgpu::RenderPipeline, +} + +impl CloudPipes { + pub(super) fn new( + device: &wgpu::Device, + module: &wgpu::ShaderModule, + viewport: &wgpu::BindGroupLayout, + target: super::GpuTarget, + depth: Option, + ) -> CloudPipes { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_cloud"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_cloud"), + bind_group_layouts: &[Some(viewport), Some(&layout)], + immediate_size: 0, + }); + let dots = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("vs_cloud_dot"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module, + entry_point: Some("vs_cloud_dot"), + buffers: &[Some(dot_layout())], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: depth, + multisample: wgpu::MultisampleState { count: target.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some("fs_cloud"), + targets: &[Some(wgpu::ColorTargetState { format: target.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + CloudPipes { layout, dots } + } +} + +/// A cloud on the GPU: its dots and its style's uniform. +struct Uploaded { + version: u64, + dots: Option, + style: wgpu::Buffer, + bind: wgpu::BindGroup, +} + +/// The clouds on the GPU, by id. +#[derive(Default)] +pub(super) struct CloudDraws(std::collections::HashMap); + +impl CloudDraws { + /// Upload new or changed clouds, drop removed ones, and write every style. + pub(super) fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pipes: &CloudPipes, clouds: &[GpuCloud]) { + self.0.retain(|id, _| clouds.iter().any(|c| c.id == *id)); + for c in clouds { + if self.0.get(&c.id).is_none_or(|u| u.version != c.version) { + let style = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sc_cloud_style"), + size: STYLE_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_cloud_style"), + layout: &pipes.layout, + entries: &[wgpu::BindGroupEntry { binding: 3, resource: style.as_entire_binding() }], + }); + let dots = upload(device, "sc_cloud", &c.dots, DOT_SIZE); + self.0.insert(c.id, Uploaded { version: c.version, dots, style, bind }); + } + if let Some(u) = self.0.get(&c.id) { + queue.write_buffer(&u.style, 0, &style_bytes(&c.style)); + } + } + } + + pub(super) fn paint(&self, pass: &mut wgpu::RenderPass<'_>, pipes: &CloudPipes, tally: &Tally) { + for u in self.0.values() { + let Some(batch) = &u.dots else { continue }; + pass.set_bind_group(1, &u.bind, &[]); + pass.set_pipeline(&pipes.dots); + for (buffer, count) in &batch.parts { + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..6, 0..*count); + tally.triangles(6, *count); + } + } + } +} + +/// What a frame's clouds add to its picture's key. +pub(super) fn hash_clouds(clouds: &[GpuCloud], h: &mut impl std::hash::Hasher) { + use std::hash::Hash as _; + for c in clouds { + c.id.hash(h); + c.version.hash(h); + style_bytes(&c.style).hash(h); + } +} diff --git a/crates/gpu/src/view/msaa_check.rs b/crates/gpu/src/view/msaa_check.rs index 0afcc6d463..a70e7ab593 100644 --- a/crates/gpu/src/view/msaa_check.rs +++ b/crates/gpu/src/view/msaa_check.rs @@ -113,7 +113,7 @@ impl Renderer { clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: [0.0; 36], access: None, diff --git a/crates/gpu/tests/capture.rs b/crates/gpu/tests/capture.rs index 130982d381..03927f4ba4 100644 --- a/crates/gpu/tests/capture.rs +++ b/crates/gpu/tests/capture.rs @@ -105,8 +105,8 @@ fn the_renderer_counts_what_it_draws() { let cam = view::home_camera(s); let scene = view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }); let triangles: usize = scene.meshes.iter().map(|m| m.mesh.triangles.len()).sum(); - c.rgba(&scene, &cam, 160, 100).unwrap(); - (c.last_stats(), triangles as u64) + let (_, stats) = c.rgba_with_stats(&scene, &cam, 160, 100).unwrap(); + (stats, triangles as u64) }; let (empty, _) = drawn(&Session::default()); assert_eq!((empty.triangles, empty.line_segments), (0, 0), "{empty:?}"); @@ -121,6 +121,32 @@ fn the_renderer_counts_what_it_draws() { assert!(two.line_segments > one.line_segments, "{two:?}"); } +/// Each picture's counts are its own while other threads draw on the same capturer: an empty +/// design never reports another thread's box, and a box never reports an empty design's +/// nothing. The counts were once read in a second call after the picture, and another thread's +/// picture could land in between. +#[test] +fn pictures_drawn_at_once_each_count_their_own() { + let Some(c) = capturer() else { return }; + let scene = |s: &Session| { + let cam = view::home_camera(s); + (view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }), cam) + }; + let empty = scene(&Session::default()); + let solid = scene(&session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))])); + std::thread::scope(|t| { + for k in 0..8 { + let (name, (sc, cam), drawn) = if k % 2 == 0 { ("empty", &empty, false) } else { ("box", &solid, true) }; + t.spawn(move || { + for i in 0..50 { + let (_, stats) = c.rgba_with_stats(sc, cam, 64, 48).unwrap(); + assert_eq!(stats.triangles > 0, drawn, "{name}, picture {i}: {stats:?}"); + } + }); + } + }); +} + /// The ground grid and the origin's axes each change the picture. #[test] fn guides_change_the_picture() { @@ -318,5 +344,44 @@ fn bad_picture_sizes_are_errors() { assert!(c.rgba(&sc, &Camera::default(), w, h).is_err(), "{w} x {h}"); } // And the device still draws. - assert!(c.rgba(&sc, &Camera::default(), 8, 8).is_ok()); + let drawn = c.rgba(&sc, &Camera::default(), 8, 8); + assert!(drawn.is_ok(), "{:?}", drawn.err()); +} + +/// Pictures read back while another thread uses their device: the app's window submits its +/// frames on the device its thumbnails and `ui.render` pictures are drawn on, and on the shared +/// capturer every picture checks its device while another one reads back. Any submit or poll can +/// take a picture's finished read-back and answer it on its own thread a moment after the +/// picture's wait is over; the picture used to give up then ("reading a picture back without +/// waiting for the browser", now and then in this binary's other tests). +#[test] +fn pictures_read_back_while_another_thread_uses_the_device() { + use std::sync::atomic::{AtomicBool, Ordering}; + let policy = solidcraft_gpu::select::Policy::from_env().unwrap(); + let Some((adapter, _)) = solidcraft_gpu::select::pick(policy) else { + assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "no GPU adapter under the {} policy", policy.name()); + eprintln!("skipping: no GPU adapter under the {} policy", policy.name()); + return; + }; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features: solidcraft_gpu::depth_features(&adapter), + required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), + ..Default::default() + })) + .unwrap(); + let c = Capturer::new(&adapter, device.clone(), queue).unwrap(); + let sc = solidcraft_engine::render::Scene::default(); + const PICTURES: usize = 2000; + let done = AtomicBool::new(false); + let failed: Vec = std::thread::scope(|t| { + t.spawn(|| { + while !done.load(Ordering::Relaxed) { + let _ = device.poll(wgpu::PollType::Poll); + } + }); + let failed = (0..PICTURES).filter_map(|i| c.rgba(&sc, &Camera::default(), 8, 8).err().map(|e| format!("picture {i}: {e}"))).collect(); + done.store(true, Ordering::Relaxed); + failed + }); + assert!(failed.is_empty(), "{} of {PICTURES} pictures failed, the first: {:?}", failed.len(), failed.first()); } diff --git a/crates/gpu/tests/depth.rs b/crates/gpu/tests/depth.rs index 255536fb4a..9c1948e863 100644 --- a/crates/gpu/tests/depth.rs +++ b/crates/gpu/tests/depth.rs @@ -471,3 +471,68 @@ fn a_cut_hides_the_edges_behind_it() { } } } + +/// #9407: a shaft in a bore it fits exactly, cut along its axis. Seen through the hub's cut, the +/// bore's wall (a back face: the hub's cap) and the shaft's side (a front face) are the same +/// cylinder, each faceted its own way, so neither is always in front: the shaft's side showed +/// through the hub's cap in streaks along the axis, changing as the view turned. The hub's cap is +/// the same as without the shaft, from several directions, in orthographic and perspective. +#[test] +fn a_shaft_in_its_bore_leaves_the_hubs_cap_whole() { + let hub = [ + ("solid.cylinder", json!({"radius": 20, "height": 40, "body_name": "Hub"})), + ("solid.cylinder", json!({"radius": 8, "height": 40, "operation": "cut"})), + ]; + let shaft = ("solid.cylinder", json!({"radius": 8, "height": 60, "base": [0, 0, -10], "body_name": "Shaft", "operation": "new"})); + let cut = ("inspect.section", json!({"plane": {"origin": [0, 0, 0], "normal": [0, -1, 0]}})); + let alone = session(&[&hub[..], std::slice::from_ref(&cut)].concat()); + let fitted = session(&[&hub[..], &[shaft, cut]].concat()); + let (w, h) = (480, 400); + for (mode, c) in capturers() { + for fov in [0.0, 0.6] { + let mut shown = Vec::new(); + for i in 0..6 { + let mut cam = view::home_camera(&fitted); + (cam.fov, cam.yaw, cam.pitch) = (fov, -1.1 + 0.3 * f64::from(i), 0.3); + let with = view::scene(&fitted, &cam); + let radius = with.radius; + // The hub alone, framed as the design with the shaft. + let without = Scene { radius, ..view::scene(&alone, &cam) }; + let shot = |sc: Scene| c.rgba(&Scene { grid: Vec::new(), ..sc }, &cam, w, h).unwrap(); + let (a, b) = (shot(without), shot(with)); + // The hub's cut on screen: a rectangle each side of the bore, its outline left out. + let rects: Vec> = [(8.0, 20.0), (-20.0, -8.0)] + .iter() + .map(|(x0, x1)| { + [(*x0, 0.0), (*x1, 0.0), (*x1, 40.0), (*x0, 40.0)] + .iter() + .filter_map(|(x, z)| cam.to_screen(Vec3::new(*x, 0.0, *z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y))) + .collect() + }) + .collect(); + let inside = |x: f64, y: f64| { + rects.iter().any(|q| { + q.len() == 4 && { + let turn = (0..4).map(|i| q[i].0 * q[(i + 1) % 4].1 - q[(i + 1) % 4].0 * q[i].1).sum::().signum(); + (0..4).all(|i| { + let ((ax, ay), (bx, by)) = (q[i], q[(i + 1) % 4]); + ((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay) * turn >= 2.0 + }) + } + }) + }; + let (mut differ, mut all) = (0, 0); + for (i, (p, q)) in pixels(&a).zip(pixels(&b)).enumerate() { + if inside((i % w as usize) as f64 + 0.5, (i / w as usize) as f64 + 0.5) { + all += 1; + differ += usize::from(p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 24)); + } + } + assert!(all > 3000, "{mode}, fov {fov}, view {i}: the hub's cap ({all} px)"); + shown.push(differ); + } + let total: usize = shown.iter().sum(); + assert!(total <= 12, "{mode} depth, fov {fov}: the shaft shows through the hub's cap, px per view {shown:?}"); + } + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index e9a701fe1a..e5f7c4dee8 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -31,6 +31,10 @@ cadmpeg-core = { workspace = true } cadmpeg-ir = { workspace = true } cadmpeg-registry = { workspace = true } +# The model cache's key (build.rs). +[build-dependencies] +sha2 = { workspace = true } + # A plain fsync where a network share refuses Apple's full flush (src/safe_write.rs); already in # the tree through tempfile. [target.'cfg(target_vendor = "apple")'.dependencies] diff --git a/crates/io/build.rs b/crates/io/build.rs index 45c77eed47..d135732ea8 100644 --- a/crates/io/build.rs +++ b/crates/io/build.rs @@ -1,20 +1,89 @@ -//! The commit the model cache is keyed by (`SOLIDCRAFT_SOURCE_COMMIT`): a cache is only the -//! result of evaluating its design when the same source computed it. "unknown" without git (a -//! source tarball), which never trusts a cache. +//! What the model cache is keyed by. `SOLIDCRAFT_EVALUATION_SOURCE`: the digest of the committed +//! source a design's model is computed from: the `doc` crate and every workspace crate it is +//! built from, the patched dependencies, the locked versions of all others, the workspace's build +//! settings and the cache's own reader. A cache is only the result of evaluating its design when +//! that source computed it, and an update that changed none of it (only the interface, the +//! commands or the other file formats, or the version number) computes the same model. +//! `SOLIDCRAFT_SOURCE_COMMIT`: the commit, recorded with the cache. Both are "unknown" without git +//! (a source tarball), which never trusts a cache. +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; use std::process::Command; -fn git(args: &[&str]) -> Option { - let out = Command::new("git").args(args).output().ok()?; - out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()).filter(|s| !s.is_empty()) +/// The workspace crates a design's model is computed from: `doc` and every workspace crate it +/// depends on (a test checks the list against their manifests). +const EVALUATION_CRATES: [&str; 6] = ["geom", "sketch", "kernel", "render", "derive", "doc"]; +/// The other files the saved model depends on, relative to the workspace root. +const EVALUATION_FILES: [&str; 3] = ["crates/io/build.rs", "crates/io/src/design/native_cache.rs", ".cargo/config.toml"]; +/// What under `vendor/` does not compute models: notes, and the interface's renderer. +const VENDOR_OUTSIDE: [&str; 2] = ["README.md", "egui-wgpu"]; + +fn git(root: &Path, args: &[&str]) -> Option { + let out = Command::new("git").arg("-C").arg(root).args(args).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()).filter(|s| !s.trim().is_empty()) +} + +/// The committed `Cargo.lock` without the workspace's own packages: their version is the +/// application's, which every release changes; the source they are built from is hashed itself. +fn locked_dependencies(lock: &str) -> String { + lock.split("\n[[package]]").filter(|block| block.lines().any(|l| l.starts_with("source = "))).collect::>().join("\n[[package]]") +} + +/// The committed workspace `Cargo.toml` without the application's version. +fn workspace_settings(manifest: &str) -> String { + let mut section = ""; + let mut kept = Vec::new(); + for line in manifest.lines() { + if line.starts_with('[') { + section = line.trim(); + } + if !(section == "[workspace.package]" && line.trim_start().starts_with("version")) { + kept.push(line); + } + } + kept.join("\n") +} + +/// The digest of the committed source of the saved model (see the module notes), or `None` when +/// a part of it is not in the commit. +fn evaluation_source(root: &Path) -> Option { + let mut paths: Vec = EVALUATION_CRATES.iter().map(|c| format!("crates/{c}")).collect(); + paths.extend(EVALUATION_FILES.iter().map(|f| f.to_string())); + let specs: Vec = paths.iter().map(|p| format!("HEAD:./{p}")).collect(); + let mut args = vec!["rev-parse"]; + args.extend(specs.iter().map(String::as_str)); + let ids = git(root, &args)?; + if ids.lines().count() != paths.len() { + return None; + } + let mut digest = Sha256::new(); + for (path, id) in paths.iter().zip(ids.lines()) { + digest.update(format!("{path} {id}\n")); + } + // `vendor/`: one line per entry, " \t". + for entry in git(root, &["ls-tree", "HEAD", "./vendor/"])?.lines() { + let name = entry.rsplit(['\t', '/']).next().unwrap_or(entry); + if !VENDOR_OUTSIDE.contains(&name) { + digest.update(format!("vendor {entry}\n")); + } + } + digest.update(locked_dependencies(&git(root, &["show", "HEAD:./Cargo.lock"])?)); + digest.update(workspace_settings(&git(root, &["show", "HEAD:./Cargo.toml"])?)); + Some(digest.finalize().iter().map(|b| format!("{b:02x}")).collect()) } fn main() { - let commit = git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".into()); + let crate_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()); + let root = crate_dir.join("../.."); + let commit = git(&crate_dir, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string()).unwrap_or_else(|| "unknown".into()); println!("cargo:rustc-env=SOLIDCRAFT_SOURCE_COMMIT={commit}"); + let source = evaluation_source(&root).unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=SOLIDCRAFT_EVALUATION_SOURCE={source}"); + println!("cargo:rustc-env=SOLIDCRAFT_EVALUATION_CRATES={}", EVALUATION_CRATES.join(",")); // A new commit in this checkout (or worktree) restamps the build. - if let Some(dir) = git(&["rev-parse", "--git-dir"]) { - println!("cargo:rerun-if-changed={dir}/logs/HEAD"); + if let Some(dir) = git(&crate_dir, &["rev-parse", "--git-dir"]) { + println!("cargo:rerun-if-changed={}/logs/HEAD", dir.trim()); } println!("cargo:rerun-if-changed=build.rs"); } diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index ac608f7757..683ed07c9d 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -1087,12 +1087,12 @@ mod tests { .collect(); let state = json!({ "bodies": [[0, bodies.len()]], "sketches": [], "body_counter": 7, "sheets": [], "construct": [], "threads": [], - "sketch_keys": [], "blend_edges": {}, "fitted": {}, "tapered": {}, + "sketch_key_set": 0, "blend_edges": {}, "fitted": {}, "tapered": {}, }); solidcraft_doc::SavedModel { records: json!({ "features": [{"id": 1, "fingerprint": "0000000000000000", "ms": 0.0, "skipped": false, "state": 0}], - "states": [state], "model_bodies": records, "names": [{"faces": [], "info": []}], "sketches": [], "sheets": [], + "states": [state], "model_bodies": records, "names": [{"faces": [], "info": []}], "sketches": [], "sheets": [], "sketch_key_sets": [{"set": [], "drop": []}], }), bodies, } @@ -1109,6 +1109,38 @@ mod tests { assert!(native_cache::source_trusted("0123abc", false)); } + /// The model cache is keyed by the source of the crates a model is computed from (#7168): + /// each one `doc` is built from must be in it, or an update that changed one would open + /// designs with the model the code before it computed. + #[test] + fn the_cache_key_covers_every_crate_the_model_is_computed_from() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let read = |p: std::path::PathBuf| std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())); + let workspace = read(root.join("Cargo.toml")); + // `solidcraft-geom = { path = "crates/geom" }`: a workspace crate's folder. + let folder = |name: &str| { + let prefix = format!("{name} = {{ path = \"crates/"); + workspace.lines().find_map(|l| l.strip_prefix(prefix.as_str())?.split('"').next().map(str::to_string)) + }; + let keyed: Vec<&str> = env!("SOLIDCRAFT_EVALUATION_CRATES").split(',').collect(); + let (mut todo, mut seen) = (vec!["doc".to_string()], BTreeSet::new()); + while let Some(krate) = todo.pop() { + if !seen.insert(krate.clone()) { + continue; + } + assert!(keyed.contains(&krate.as_str()), "crates/{krate} computes models: add it to EVALUATION_CRATES in crates/io/build.rs"); + let mut built_with = false; + for line in read(root.join("crates").join(&krate).join("Cargo.toml")).lines() { + if line.starts_with('[') { + built_with = line.ends_with("dependencies]") && !line.contains("dev-dependencies"); + } else if built_with && let Some(dir) = line.split(" = ").next().filter(|n| n.starts_with("solidcraft-")).and_then(folder) { + todo.push(dir); + } + } + } + assert_eq!(seen.len(), keyed.len(), "{seen:?} computed from, {keyed:?} keyed"); + } + #[test] fn native_cache_round_trip_and_preview_defer_original_sources() { let mut doc = Document::new("Native cache"); @@ -1158,6 +1190,63 @@ mod tests { } } + /// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the + /// model, as its own triangles, colour and paint. One mesh body left the whole cache out, so + /// opening the design evaluated every feature again. + #[test] + fn model_cache_keeps_mesh_bodies_with_the_rest_of_the_model() { + use solidcraft_geom::Vec3; + let corners = [Vec3::ZERO, Vec3::new(10., 0., 0.), Vec3::new(0., 10., 0.), Vec3::new(0., 0., 10.)]; + let tetra = solidcraft_kernel::mesh_body(&corners, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]]) + .unwrap() + .with_color(Some([0.25, 0.5, 0.75])) + .with_paint(Some(solidcraft_kernel::Paint { opacity: 0.5, faces: Vec::new() })); + let block = solidcraft_kernel::box_solid(Vec3::new(40., 0., 0.), Vec3::new(60., 20., 20.)).unwrap(); + let model = saved_model(vec![block, tetra.clone()]); + let doc = document(); + let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&model)).unwrap(); + let entries = parts(&bytes); + let manifest: Value = serde_json::from_slice(&entries["cache/model.json"]).expect("the cache is written"); + assert_eq!(manifest["slices"].as_array().unwrap().len(), 2); + let read = DesignContainerReader::new(Cursor::new(&bytes)).unwrap().geometry().unwrap(); + assert_eq!(read.records, model.records); + assert_eq!(solidcraft_kernel::encode_native_body(&read.bodies[0]).unwrap(), solidcraft_kernel::encode_native_body(&model.bodies[0]).unwrap()); + let mesh = &read.bodies[1]; + assert_eq!(mesh.triangle_mesh(), tetra.triangle_mesh()); + assert_eq!((mesh.color(), mesh.paint()), (tetra.color(), tetra.paint())); + // The picture shown while opening draws the mesh body with its own triangles. + let preview = read_design_preview(&bytes).unwrap(); + assert_eq!(preview.meshes.len(), 2); + let (positions, triangles) = tetra.triangle_mesh().unwrap(); + assert_eq!(&*preview.meshes[1].positions, &positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect::>()[..]); + assert_eq!(&*preview.meshes[1].triangles, &triangles.iter().flatten().copied().collect::>()[..]); + assert_eq!(preview.meshes[1].color, tetra.color()); + + // A changed or cut-short mesh slice (signed again, so it reaches the slice's reader) is a + // cache miss or another mesh, never a crash. + let range = |i: usize| manifest["slices"][1][i].as_u64().unwrap() as usize; + let (start, length) = (range(0), range(1)); + let payload = entries["cache/bodies.bin"].clone(); + assert_eq!(payload.len(), start + length, "the mesh slice is the last"); + let with = |payload: &[u8], length: usize| { + let mut entries = entries.clone(); + let mut manifest = manifest.clone(); + manifest["payload"] = json!(digest(payload)); + manifest["slices"][1][1] = json!(length); + entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap()); + entries.insert("cache/bodies.bin".into(), payload.to_vec()); + package(&entries) + }; + assert!(read_design_geometry(&with(&payload, length)).is_some(), "signed again as it was, it reads"); + for n in (start..start + length).step_by(5) { + let mut changed = payload.clone(); + changed[n] ^= 0xff; + let changed = with(&changed, length); + assert!(std::panic::catch_unwind(|| (read_design_geometry(&changed), read_design_preview(&changed))).is_ok(), "byte {n}"); + assert!(read_design_geometry(&with(&payload[..n], n - start)).is_none(), "cut short at {n}"); + } + } + /// A model cache that does not fit the design file is left out: the save succeeds, and the /// design reads back as saved. #[test] @@ -1188,7 +1277,7 @@ mod tests { let bodies = if i == 0 { json!([[0, 16]]) } else { json!([[0, 8], [8 * (i + 1), 8]]) }; json!({ "bodies": bodies, "sketches": [], "body_counter": 7, "sheets": [], "construct": [], "threads": [], - "sketch_keys": [], "blend_edges": {}, "fitted": {}, "tapered": {}, + "sketch_key_set": 0, "blend_edges": {}, "fitted": {}, "tapered": {}, }) }) .collect(); @@ -1198,7 +1287,7 @@ mod tests { .map(|(i, ms)| json!({"id": i + 1, "fingerprint": "0000000000000000", "ms": ms, "skipped": false, "state": i})) .collect(); solidcraft_doc::SavedModel { - records: json!({"features": features, "states": states, "model_bodies": records, "names": [{"faces": [], "info": []}], "sketches": [], "sheets": []}), + records: json!({"features": features, "states": states, "model_bodies": records, "names": [{"faces": [], "info": []}], "sketches": [], "sheets": [], "sketch_key_sets": [{"set": [], "drop": []}]}), bodies, } } @@ -1248,7 +1337,7 @@ mod tests { one.records["states"], json!([{ "bodies": [[0, 16]], "sketches": [], "body_counter": 7, "sheets": [], "construct": [], "threads": [], - "sketch_keys": [], "blend_edges": {}, "fitted": {}, "tapered": {}, + "sketch_key_set": 0, "blend_edges": {}, "fitted": {}, "tapered": {}, }]) ); } @@ -1314,7 +1403,7 @@ mod tests { let doc = document(); let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap(); let original = parts(&bytes); - for key in ["format", "producer", "app", "build", "document", "payload"] { + for key in ["format", "producer", "evaluation", "document", "payload"] { let mut entries = original.clone(); let mut manifest: Value = serde_json::from_slice(&entries["cache/model.json"]).unwrap(); manifest[key] = json!("unsupported-or-stale"); @@ -1324,6 +1413,24 @@ mod tests { assert!(read_design_preview(&bytes).is_none()); assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json()); } + // A cache of the format before sketch-key sets (/4) is a miss, and so are records of + // that shape (each state's keys inline) under the current format: never misread. + let manifest: Value = serde_json::from_slice(&original["cache/model.json"]).unwrap(); + let mut older = manifest.clone(); + older["format"] = json!("solidcraft-model-cache/4"); + let mut inline = manifest.clone(); + let model = inline["model"].as_object_mut().unwrap(); + model.remove("sketch_key_sets"); + let state = model["states"][0].as_object_mut().unwrap(); + state.remove("sketch_key_set"); + state.insert("sketch_keys".into(), json!([])); + for manifest in [older, inline] { + let mut entries = original.clone(); + entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap()); + let bytes = package(&entries); + assert!(read_design_geometry(&bytes).is_none(), "{}", manifest["format"]); + assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json()); + } let mut entries = original; entries.get_mut("cache/bodies.bin").unwrap()[0] ^= 1; let bytes = package(&entries); @@ -1331,6 +1438,47 @@ mod tests { assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json()); } + /// #7168: a design saved by another version of SolidCraft (another version number and commit) + /// whose model was computed by the same evaluation source opens from its model cache, so an + /// update that changed only the interface or the commands does not evaluate every design again. + /// One computed by other evaluation source (a kernel or codec change) is evaluated again. + #[test] + fn a_cache_of_another_version_with_the_same_evaluation_source_is_read() { + let doc = document(); + let geometry = native_geometry(); + let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&geometry)).unwrap(); + let written = |entries: &BTreeMap>| serde_json::from_slice::(&entries["cache/model.json"]).unwrap(); + let original = parts(&bytes); + let evaluation = written(&original)["evaluation"].as_str().unwrap().to_string(); + assert!(evaluation.ends_with(&format!("/{}-{}", std::env::consts::ARCH, std::env::consts::OS)), "{evaluation}"); + let with = |changes: &[(&str, &str)]| { + let mut entries = original.clone(); + let mut manifest = written(&entries); + for (key, value) in changes { + manifest[*key] = json!(value); + } + entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap()); + package(&entries) + }; + let older = with(&[("app", "0.0.1"), ("build", "0123456789abcdef0123456789abcdef01234567/other-platform")]); + let state = read_design_geometry(&older).expect("another version's cache of the same evaluation source"); + assert_eq!(state.bodies[0].body.face_count(), geometry.bodies[0].face_count()); + assert_eq!(crate::read_design(&older).unwrap().to_json(), doc.to_json()); + // The same version with other evaluation source, or on another platform, evaluates again. + let other_source = format!("{}/{}", "0".repeat(64), evaluation.rsplit('/').next().unwrap()); + for changed in [other_source.as_str(), &format!("{}/other-platform", evaluation.rsplit_once('/').unwrap().0)] { + let bytes = with(&[("evaluation", changed)]); + assert!(read_design_geometry(&bytes).is_none(), "{changed}"); + assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json()); + } + // An older build's cache (no evaluation source recorded) is evaluated again. + let mut entries = original.clone(); + let mut manifest = written(&entries); + manifest.as_object_mut().unwrap().remove("evaluation"); + entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap()); + assert!(read_design_geometry(&package(&entries)).is_none()); + } + #[test] fn metadata_snapshot_survives_in_place_changes_after_preview() { use std::{cell::RefCell, rc::Rc}; diff --git a/crates/io/src/design/native_cache.rs b/crates/io/src/design/native_cache.rs index 9876d4f49d..72b82b732f 100644 --- a/crates/io/src/design/native_cache.rs +++ b/crates/io/src/design/native_cache.rs @@ -1,11 +1,12 @@ //! The model cache: a design's evaluated timeline (every feature's result and the state after //! it, [`SavedModel`]), saved with it so opening need not evaluate it again. Bodies use -//! SolidCraft's exact native B-rep encoding; the key ties the cache to the exact document, build -//! and platform that computed it (docs/design-container.md). +//! SolidCraft's exact native B-rep encoding (a mesh body, its own triangles); the key ties the +//! cache to the exact document, and to the source and platform that computed it +//! (docs/design-container.md). use super::*; use solidcraft_doc::{ModelState, SavedModel}; -const FORMAT: &str = "solidcraft-model-cache/4"; +const FORMAT: &str = "solidcraft-model-cache/5"; const PRODUCER: &str = "solidcraft-truck-adapter/3"; const MANIFEST: &str = "cache/model.json"; const PAYLOAD: &str = "cache/bodies.bin"; @@ -15,12 +16,19 @@ const MAX_RECORDS: usize = 200_000; const MAX_UNIQUE: usize = 100_000; const MAX_MANIFEST: usize = 32 << 20; -/// The build that computed a cache: the same source, compiled for the same platform, computes the -/// same numbers, so only its own caches are its results. +/// The build that wrote a cache (recorded; earlier builds read only their own caches). fn build() -> String { format!("{}/{}-{}", env!("SOLIDCRAFT_SOURCE_COMMIT"), std::env::consts::ARCH, std::env::consts::OS) } +/// What computed a cache: the same evaluation source (build.rs), compiled for the same platform, +/// computes the same numbers, so only caches of that source are its results. An update that left +/// that source as it was (only the interface or the commands changed) reads the caches of the +/// build before it; one that changed it (a kernel fix, the codec) evaluates the design again. +fn evaluation() -> String { + format!("{}/{}-{}", env!("SOLIDCRAFT_EVALUATION_SOURCE"), std::env::consts::ARCH, std::env::consts::OS) +} + /// A 4 x 4 matrix as its 16 numbers, in storage order. fn matrix(t: &[[f64; 4]; 4]) -> Vec { t.iter().flatten().copied().collect() @@ -107,6 +115,90 @@ fn same_topology(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body) -> bo counts(a) == counts(b) } +/// The start of a mesh body's slice. A mesh body (an imported STL or 3MF) has no B-rep for the +/// native encoding: its slice is its triangles as they are, with its colour and paint, which is +/// all a mesh body holds. A B-rep slice starts with the native encoding's own tag. +const MESH_TAG: &[u8; 8] = b"SCMESH01"; +/// Most bytes of a mesh slice's header (its counts, colour and paint). +const MAX_MESH_HEADER: usize = 16 << 20; + +/// A mesh body's slice: the tag, the header's length (u32) and the header (JSON: the counts, +/// colour and paint), then each vertex as three f64 and each triangle as three u32, little-endian. +fn encode_mesh_body(body: &solidcraft_kernel::Body) -> Option> { + let (positions, triangles) = body.triangle_mesh()?; + let header = + serde_json::to_vec(&json!({"positions": positions.len(), "triangles": triangles.len(), "color": body.color(), "paint": body.paint()})) + .ok()?; + if header.len() > MAX_MESH_HEADER { + return None; + } + let size = positions.len().checked_mul(24)?.checked_add(triangles.len().checked_mul(12)?)?.checked_add(12 + header.len())?; + let mut out = Vec::with_capacity(size); + out.extend_from_slice(MESH_TAG); + out.extend_from_slice(&u32::try_from(header.len()).ok()?.to_le_bytes()); + out.extend_from_slice(&header); + for p in positions { + for x in [p.x, p.y, p.z] { + out.extend_from_slice(&x.to_le_bytes()); + } + } + for t in triangles { + for i in t { + out.extend_from_slice(&i.to_le_bytes()); + } + } + Some(out) +} + +/// The mesh body of a slice [`encode_mesh_body`] wrote; `None` for anything else. +fn decode_mesh_body(bytes: &[u8]) -> Option { + let rest = bytes.strip_prefix(MESH_TAG.as_slice())?; + let (length, rest) = rest.split_first_chunk::<4>()?; + let length = usize::try_from(u32::from_le_bytes(*length)).ok().filter(|n| *n <= MAX_MESH_HEADER)?; + let (header, data) = rest.split_at_checked(length)?; + let header: Value = serde_json::from_slice(header).ok()?; + let count = |key: &str| header.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()); + let (vertices, triangles) = (count("positions")?, count("triangles")?); + if triangles > solidcraft_kernel::MAX_TRIANGLES { + return None; + } + // The counts must account for every byte, so nothing is allocated for data that is not there. + let split = vertices.checked_mul(24)?; + if split.checked_add(triangles.checked_mul(12)?)? != data.len() { + return None; + } + let (coordinates, corners) = data.split_at_checked(split)?; + let f64_at = |c: &[u8], at: usize| c.get(at..at + 8)?.try_into().ok().map(f64::from_le_bytes); + let u32_at = |c: &[u8], at: usize| c.get(at..at + 4)?.try_into().ok().map(u32::from_le_bytes); + let positions: Vec = coordinates + .as_chunks::<24>() + .0 + .iter() + .map(|c| Some(solidcraft_geom::Vec3::new(f64_at(c, 0)?, f64_at(c, 8)?, f64_at(c, 16)?))) + .collect::>()?; + let triangles: Vec<[u32; 3]> = + corners.as_chunks::<12>().0.iter().map(|c| Some([u32_at(c, 0)?, u32_at(c, 4)?, u32_at(c, 8)?])).collect::>()?; + let color: Option<[f32; 3]> = serde_json::from_value(header.get("color").cloned().unwrap_or(Value::Null)).ok()?; + let paint: Option = serde_json::from_value(header.get("paint").cloned().unwrap_or(Value::Null)).ok()?; + if color.is_some_and(|c| c.iter().any(|x| !x.is_finite())) { + return None; + } + Some(solidcraft_kernel::mesh_body(&positions, &triangles).ok()?.with_color(color).with_paint(paint)) +} + +/// A mesh body decoded is the one encoded: the same vertices, bit for bit, the same triangles, +/// colour and paint. +fn same_mesh(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body) -> bool { + let (Some((pa, ta)), Some((pb, tb))) = (a.triangle_mesh(), b.triangle_mesh()) else { return false }; + let bits = |p: &[solidcraft_geom::Vec3]| p.iter().map(|v| [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]).collect::>(); + ta == tb && bits(pa) == bits(pb) && a.color() == b.color() && a.paint() == b.paint() +} + +/// A body of the cache's payload, mesh or B-rep by the slice's tag. +fn decode_cached_body(bytes: &[u8]) -> Option { + if bytes.starts_with(MESH_TAG) { decode_mesh_body(bytes) } else { solidcraft_kernel::decode_native_body(bytes).ok() } +} + impl Encoded { fn of(model: &SavedModel) -> Result { // What to encode: every body, and the part every placed copy shows. @@ -127,6 +219,10 @@ impl Encoded { // topology (two loose vertices more on a pattern copy cut by an extrude) would open as // another body. Then the model isn't kept, and opening the design evaluates it. let encoded = solidcraft_kernel::map_on_threads(&bodies, 16, |b| { + if b.is_mesh() { + let bytes = encode_mesh_body(b)?; + return decode_mesh_body(&bytes).is_some_and(|back| same_mesh(b, &back)).then_some(bytes); + } let bytes = solidcraft_kernel::encode_native_body(b).ok()?; let back = solidcraft_kernel::decode_native_body(&bytes).ok()?; same_topology(b, &back).then_some(bytes) @@ -195,7 +291,7 @@ impl Encoded { } let manifest = serde_json::to_vec(&json!({ "format": FORMAT, "producer": PRODUCER, "app": env!("CARGO_PKG_VERSION"), "build": build(), - "document": digest(document), "payload": digest(&data), + "evaluation": evaluation(), "document": digest(document), "payload": digest(&data), "slices": slices, "shares": shares, "bodies": table, "model": records, })) .map_err(invalid)?; @@ -203,8 +299,8 @@ impl Encoded { } } -/// The final state of the model a design keeps, when this build computed it for exactly this -/// document. Any failure is a miss, never a document error. +/// The final state of the model a design keeps, when this build's evaluation source computed it +/// for exactly this document. Any failure is a miss, never a document error. pub fn read_design_geometry(bytes: &[u8]) -> Option> { if bytes.len() > max_design() || !is_design_container(bytes) { return None; @@ -212,15 +308,16 @@ pub fn read_design_geometry(bytes: &[u8]) -> Option> DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()?.final_state().ok() } -/// Whether `manifest` is a model cache this build wrote for `document`. +/// Whether `manifest` is a model cache of this build's evaluation source for `document`. The +/// application version and commit that wrote it are not asked: another version of the same +/// evaluation source computed the same model. fn current(manifest: &Value, document: &[u8]) -> bool { let is = |key: &str, value: &str| manifest.get(key).and_then(Value::as_str) == Some(value); is("format", FORMAT) && is("producer", PRODUCER) - && is("app", env!("CARGO_PKG_VERSION")) - && is("build", &build()) + && is("evaluation", &evaluation()) && is("document", &digest(document)) - && source_trusted(env!("SOLIDCRAFT_SOURCE_COMMIT"), cfg!(any(test, feature = "trust-unknown-source"))) + && source_trusted(env!("SOLIDCRAFT_EVALUATION_SOURCE"), cfg!(any(test, feature = "trust-unknown-source"))) } /// A build of unknown source (no git) never trusts a cache, unless `in_tests`: test builds @@ -264,7 +361,7 @@ pub(super) fn read_geometry_with(reader: &mut PackageReader, if end != data.len() { return Err(invalid("trailing cached data")); } - let decoded = solidcraft_kernel::map_on_threads(&parts, 16, |bytes| solidcraft_kernel::decode_native_body(bytes).ok()); + let decoded = solidcraft_kernel::map_on_threads(&parts, 16, |bytes| decode_cached_body(bytes)); let unique = decoded.into_iter().map(Option::flatten).collect::>>().ok_or_else(|| invalid("cached body"))?; let body = |v: Option<&Value>| unique.get(count(v)?).ok_or_else(|| invalid("cached body index")); let shares = manifest @@ -322,16 +419,24 @@ pub(super) fn geometry_preview(document: &[u8], model: &SavedModel) -> Result own, + None => { + made = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?; + (made.positions.as_slice(), made.triangles.as_slice()) + } + }; + triangles = triangles.saturating_add(faces.len()); + display_bytes = display_bytes.saturating_add(positions.len().saturating_mul(24)).saturating_add(faces.len().saturating_mul(12)); if triangles > 1_000_000 || display_bytes > 128 << 20 { return Err(invalid("regenerated display budget")); } let displayed = MeshData { name: entry.name.clone(), - positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(), - triangles: mesh.triangles.iter().flatten().copied().collect(), + positions: positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(), + triangles: faces.iter().flatten().copied().collect(), color: entry.body.color(), }; validate_mesh(&displayed)?; diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index 7881672e62..6a2c93c9f3 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -4,10 +4,11 @@ //! Curves stay exact: a circle is a CIRCLE, a circular arc an ARC, a whole ellipse one ELLIPSE //! and a conic arc of weight below 1 an elliptical ELLIPSE arc; Béziers (TrueType glyph //! outlines, spline pieces) and the other conics are SPLINEs of their own degree. The file holds -//! what the entities refer to and what release 2000 needs to read them: the version, the -//! handle seed, the units and the extents; the linetype, layer and block-record tables; the -//! model and paper space blocks; handles, owners and the subclass markers of each entity; the -//! root dictionary. Layers can be dashed and a drawing can carry TEXT (for annotations). Group +//! what the entities refer to and what release 2000 needs to read them: the version, code page, +//! handle seed, units and extents; the (empty) CLASSES section; all nine symbol tables, with the +//! active viewport, the linetypes, layers, text style, the ACAD application id, the dimension +//! style and the block records; the model and paper space blocks; handles, owners and the +//! subclass markers of each entity; the root dictionary and its group dictionary. Layers can be dashed and a drawing can carry TEXT (for annotations). Group //! codes follow Autodesk's public DXF reference. use std::f64::consts::TAU; @@ -267,6 +268,11 @@ impl Out { self.num(c + 10, p.y); self.num(c + 20, 0.0); } + /// A 2D point: codes `c` and `c + 10`. + fn point2(&mut self, c: i32, p: Vec2) { + self.num(c, p.x); + self.num(c + 10, p.y); + } fn handle(&mut self) -> String { let h = format!("{:X}", self.next); self.next += 1; @@ -366,10 +372,48 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result< let (model, paper) = (o.handle(), o.handle()); let root = o.handle(); + // Release 2000 readers expect the CLASSES section, even empty, between HEADER and TABLES. + o.code(0, "SECTION"); + o.code(2, "CLASSES"); + o.code(0, "ENDSEC"); + + // All nine symbol tables, in the reference's order, even those with nothing to hold: a + // strict reader (AutoCAD among them, #9144) refuses a file that leaves one out. o.code(0, "SECTION"); o.code(2, "TABLES"); - // The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths - // (a gap is negative) after the pattern's total. + // The active viewport, looking down on the whole drawing so it opens zoomed to fit. + let t = o.table("VPORT", 1); + o.record("VPORT", "AcDbViewportTableRecord", &t); + o.code(2, "*Active"); + o.code(70, 0); + o.point2(10, Vec2::ZERO); + o.point2(11, Vec2::new(1.0, 1.0)); + o.point2(12, (lo + hi) * 0.5); + o.point2(13, Vec2::ZERO); + o.point2(14, Vec2::new(10.0, 10.0)); + o.point2(15, Vec2::new(10.0, 10.0)); + o.num(16, 0.0); + o.num(26, 0.0); + o.num(36, 1.0); + o.num(17, 0.0); + o.num(27, 0.0); + o.num(37, 0.0); + const ASPECT: f64 = 1.5; + let size = hi - lo; + let height = (size.y.max(size.x / ASPECT) * 1.1).max(1.0); + o.num(40, if height.is_finite() { height } else { 1.0 }); + o.num(41, ASPECT); + o.num(42, 50.0); + o.num(43, 0.0); + o.num(44, 0.0); + o.num(50, 0.0); + o.num(51, 0.0); + for (code, value) in [(71, 0), (72, 1000), (73, 1), (74, 3), (75, 0), (76, 0), (77, 0), (78, 0)] { + o.code(code, value); + } + o.code(0, "ENDTAB"); + // The three linetypes every drawing has, and DASHED, which the dashed layers name: dash and + // gap lengths (a gap is negative) after the pattern's total. let mut linetypes: Vec<(&str, &str, &[f64])> = vec![ ("ByBlock", "", &[]), ("ByLayer", "", &[]), @@ -416,6 +460,29 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result< o.code(3, "txt"); o.code(4, ""); o.code(0, "ENDTAB"); + for name in ["VIEW", "UCS"] { + o.table(name, 0); + o.code(0, "ENDTAB"); + } + // The application id every drawing registers. + let t = o.table("APPID", 1); + o.record("APPID", "AcDbRegAppTableRecord", &t); + o.code(2, "ACAD"); + o.code(70, 0); + o.code(0, "ENDTAB"); + // The one dimension style, with the default settings. The table has a subclass of its own + // and its records keep their handle under 105, not 5. + let t = o.table("DIMSTYLE", 1); + o.code(100, "AcDbDimStyleTable"); + let h = o.handle(); + o.code(0, "DIMSTYLE"); + o.code(105, h); + o.code(330, &t); + o.code(100, "AcDbSymbolTableRecord"); + o.code(100, "AcDbDimStyleTableRecord"); + o.code(2, "Standard"); + o.code(70, 0); + o.code(0, "ENDTAB"); let t = o.table("BLOCK_RECORD", 2); for (h, name) in [(&model, "*Model_Space"), (&paper, "*Paper_Space")] { o.code(0, "BLOCK_RECORD"); @@ -535,11 +602,20 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result< o.code(0, "SECTION"); o.code(2, "OBJECTS"); + // The root dictionary, which must name the group dictionary (empty: no groups). + let groups = o.handle(); o.code(0, "DICTIONARY"); o.code(5, root.as_str()); o.code(330, 0); o.code(100, "AcDbDictionary"); o.code(281, 1); + o.code(3, "ACAD_GROUP"); + o.code(350, &groups); + o.code(0, "DICTIONARY"); + o.code(5, &groups); + o.code(330, &root); + o.code(100, "AcDbDictionary"); + o.code(281, 1); o.code(0, "ENDSEC"); o.code(0, "EOF"); @@ -548,6 +624,10 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result< head.code(2, "HEADER"); head.code(9, "$ACADVER"); head.code(1, "AC1015"); + // Every character in the file is ASCII (other letters are `\U+XXXX`), which this code page + // reads as itself. + head.code(9, "$DWGCODEPAGE"); + head.code(3, "ANSI_1252"); head.code(9, "$HANDSEED"); head.code(5, format!("{:X}", o.next)); head.code(9, "$INSUNITS"); @@ -966,6 +1046,76 @@ mod tests { } } + #[test] + fn a_strict_reader_finds_every_section_table_and_the_group_dictionary() { + // #9144: AutoCAD refused the exported view. A release 2000 file has the CLASSES section, + // all nine symbol tables with their standard records, and a root dictionary naming + // ACAD_GROUP. + let ents = [ + DxfEntity { layer: "VISIBLE".into(), shape: DxfShape::Line(Vec2::new(0.0, 0.0), Vec2::new(30.0, 0.0)) }, + DxfEntity { layer: "VISIBLE".into(), shape: DxfShape::Circle { c: Vec2::new(15.0, 5.0), r: 5.0 } }, + ]; + let dxf = write_dxf_drawing(&[DxfLayer::new("VISIBLE", 7)], &ents).unwrap(); + let p = pairs(&dxf); + let sections: Vec<&str> = p.windows(2).filter(|w| w[0] == (0, "SECTION".into())).map(|w| w[1].1.as_str()).collect(); + assert_eq!(sections, ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"]); + let tables: Vec<&str> = p.windows(2).filter(|w| w[0] == (0, "TABLE".into())).map(|w| w[1].1.as_str()).collect(); + assert_eq!(tables, ["VPORT", "LTYPE", "LAYER", "STYLE", "VIEW", "UCS", "APPID", "DIMSTYLE", "BLOCK_RECORD"]); + // Each table says how many records it holds, and holds them. + for table in tables { + let at = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, table)).unwrap(); + let count: usize = p[at..].iter().find(|(c, _)| *c == 70).unwrap().1.parse().unwrap(); + assert_eq!(table_records(&p, table).len(), count, "{table}"); + } + assert_eq!(table_records(&p, "APPID").iter().map(|r| field(r, 2)).collect::>(), ["ACAD"]); + let dimstyles = table_records(&p, "DIMSTYLE"); + // (The DIMSTYLE table's own subclass comes before its record.) + let [standard] = dimstyles.as_slice() else { panic!("{dimstyles:?}") }; + assert_eq!(field(standard, 2), "Standard"); + assert!(!field(standard, 105).is_empty() && field(standard, 5).is_empty(), "{standard:?}"); + // The active viewport looks down on the middle of the drawing (0, 0)..(30, 10), all of it in view. + let vports = table_records(&p, "VPORT"); + let [active] = vports.as_slice() else { panic!("{vports:?}") }; + assert_eq!(field(active, 2), "*Active"); + assert_eq!((field(active, 12), field(active, 22), field(active, 36)), ("15.0", "5.0", "1.0")); + let (height, aspect): (f64, f64) = (field(active, 40).parse().unwrap(), field(active, 41).parse().unwrap()); + assert!(height >= 10.0 && height * aspect >= 30.0, "{height} {aspect}"); + // The root dictionary names the group dictionary, which it owns. + let objects = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "OBJECTS")).unwrap(); + let mut dictionaries: Vec> = Vec::new(); + for (c, v) in p[objects + 1..].iter().take_while(|(c, v)| (*c, v.as_str()) != (0, "ENDSEC")) { + match c { + 0 => dictionaries.push(Vec::new()), + _ => dictionaries.last_mut().unwrap().push((*c, v.clone())), + } + } + let [root, groups] = dictionaries.as_slice() else { panic!("{dictionaries:?}") }; + assert_eq!((field(root, 330), field(root, 3)), ("0", "ACAD_GROUP")); + assert_eq!(field(root, 350), field(groups, 5)); + assert_eq!(field(groups, 330), field(root, 5)); + // Handles, under 5 and the DIMSTYLE's 105, are unique and below the seed. + let seed_at = p.iter().position(|(c, v)| (*c, v.as_str()) == (9, "$HANDSEED")).unwrap() + 1; + let seed = u64::from_str_radix(&p[seed_at].1, 16).unwrap(); + let mut handles: Vec = p + .iter() + .enumerate() + .filter(|(i, (c, _))| *i != seed_at && (*c == 5 || *c == 105)) + .map(|(_, (_, v))| u64::from_str_radix(v, 16).unwrap()) + .collect(); + let n = handles.len(); + handles.sort_unstable(); + handles.dedup(); + assert_eq!(handles.len(), n); + assert!(handles.iter().all(|h| *h < seed)); + // Every owner (330) is 0 or a handle in the file. + for (_, v) in p.iter().filter(|(c, _)| *c == 330) { + let h = u64::from_str_radix(v, 16).unwrap(); + assert!(h == 0 || handles.binary_search(&h).is_ok(), "owner {v}"); + } + // SolidCraft's own reader still reads it. + assert_eq!(read_dxf(&dxf).unwrap().len(), 2); + } + #[test] fn the_extents_hold_the_text() { // Five letters of 3 mm, turned to read upwards: 0.8 × 3 × 5 = 12 mm long around y = 12, and diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b172a6467a..6b2d1c2a3f 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -28,6 +28,7 @@ mod native_cad; pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step}; pub mod paper; mod ply; +mod ply_complex; pub use ply::{ply_counts, read_ply_mesh}; mod point_cloud; pub use point_cloud::{CloudFile, DEFAULT_MAX_POINTS, MAX_POINT_CLOUD_BYTES, is_ply_path, is_point_cloud_path, read_point_cloud}; @@ -824,6 +825,12 @@ pub fn read_design(bytes: &[u8]) -> Result { if is_design_container(bytes) { return design::read(bytes); } + // Every DWG starts with its version (`AC1032`), whatever the file is called (#9328). + if bytes.starts_with(b"AC1") { + return Err(IoError::Invalid( + "this is a DWG file, which SolidCraft can't read: export the model as STEP and open that, or the drawing as DXF and use Insert DXF in a sketch".into(), + )); + } let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("design file is not UTF-8 JSON".into()))?; // Only a file that isn't a design pays for asking whether it is a drawing. Document::from_json(s).map_err(|e| match file_kind(bytes) { @@ -1109,6 +1116,8 @@ mod tests { let back = read_design(&write_design(&doc)).unwrap(); assert_eq!(back, doc); assert!(read_design(b"\xff\xfe").is_err()); + let dwg = read_design(b"AC1032\0\0\0\0\0\xff\xfe").unwrap_err().to_string(); + assert!(dwg.contains("this is a DWG file") && dwg.contains("STEP") && dwg.contains("DXF"), "{dwg}"); assert!(matches!(export(&m.state(), &["nope".into()], Format::Step, "t"), Err(IoError::Empty))); assert_eq!(Format::from_name("a.STP").unwrap(), Format::Step); assert!(is_step_path("/x/Part.STEP") && is_step_path("a.stp") && !is_step_path("a.step.json") && !is_step_path("step")); diff --git a/crates/io/src/ply.rs b/crates/io/src/ply.rs index fc6c8fca57..5b702905b0 100644 --- a/crates/io/src/ply.rs +++ b/crates/io/src/ply.rs @@ -25,7 +25,7 @@ fn invalid(m: impl std::fmt::Display) -> IoError { } #[derive(Clone, Copy, Debug, PartialEq)] -enum Scalar { +pub(crate) enum Scalar { I8, U8, I16, @@ -50,7 +50,7 @@ impl Scalar { _ => return None, }) } - fn size(self) -> usize { + pub(crate) fn size(self) -> usize { match self { Scalar::I8 | Scalar::U8 => 1, Scalar::I16 | Scalar::U16 => 2, @@ -79,7 +79,7 @@ impl Scalar { }) } /// A colour channel of this type as a byte: integers by their range, floats from 0..1. - fn channel(self, v: f64) -> u8 { + pub(crate) fn channel(self, v: f64) -> u8 { let v = match self { Scalar::F32 | Scalar::F64 => v * 255.0, Scalar::U16 | Scalar::I16 => v / 257.0, @@ -91,23 +91,23 @@ impl Scalar { } #[derive(Clone, Copy, Debug, PartialEq)] -enum Kind { +pub(crate) enum Kind { Scalar(Scalar), /// The count's type and the items' type. List(Scalar, Scalar), } #[derive(Clone, Debug)] -struct Property { - name: String, - kind: Kind, +pub(crate) struct Property { + pub(crate) name: String, + pub(crate) kind: Kind, } #[derive(Clone, Debug)] -struct Element { - name: String, - count: u64, - properties: Vec, +pub(crate) struct Element { + pub(crate) name: String, + pub(crate) count: u64, + pub(crate) properties: Vec, } impl Element { @@ -124,7 +124,7 @@ impl Element { } #[derive(Clone, Copy, Debug, PartialEq)] -enum Encoding { +pub(crate) enum Encoding { Ascii, Little, Big, @@ -132,10 +132,12 @@ enum Encoding { /// What the header says, and where the rows start. #[derive(Debug)] -struct Header { - encoding: Encoding, - elements: Vec, - body: usize, +pub(crate) struct Header { + pub(crate) encoding: Encoding, + pub(crate) elements: Vec, + pub(crate) body: usize, + /// The text of the `comment` lines. + pub(crate) comments: Vec, } /// Does `bytes` start like a PLY file? @@ -143,7 +145,7 @@ pub fn is_ply(bytes: &[u8]) -> bool { bytes.starts_with(b"ply") && bytes.get(3).is_some_and(|b| matches!(b, b'\n' | b'\r')) } -fn header(bytes: &[u8]) -> Result
{ +pub(crate) fn header(bytes: &[u8]) -> Result
{ if !is_ply(bytes) { return Err(invalid("not a PLY file (it must start with `ply`)")); } @@ -163,7 +165,12 @@ fn header(bytes: &[u8]) -> Result
{ let text = String::from_utf8_lossy(head.get(..end).unwrap_or_default()); let mut encoding = None; let mut elements: Vec = Vec::new(); + let mut comments = Vec::new(); for line in text.split(['\n', '\r']).skip(1) { + if let Some(c) = line.strip_prefix("comment ") { + comments.push(c.trim().to_string()); + continue; + } let mut it = line.split_whitespace(); match it.next() { Some("format") => { @@ -206,11 +213,11 @@ fn header(bytes: &[u8]) -> Result
{ } } let encoding = encoding.ok_or_else(|| invalid("no `format` line"))?; - Ok(Header { encoding, elements, body }) + Ok(Header { encoding, elements, body, comments }) } /// A row's values, read one after another. -enum Rows<'a> { +pub(crate) enum Rows<'a> { Binary { b: &'a [u8], pos: usize, big: bool }, Ascii { tokens: std::slice::Split<'a, u8, fn(&u8) -> bool> }, } @@ -220,7 +227,7 @@ fn space(b: &u8) -> bool { } impl<'a> Rows<'a> { - fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> { + pub(crate) fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> { let b = bytes.get(h.body..).unwrap_or_default(); match h.encoding { Encoding::Ascii => Rows::Ascii { tokens: b.split(space as fn(&u8) -> bool) }, @@ -228,7 +235,7 @@ impl<'a> Rows<'a> { Encoding::Big => Rows::Binary { b, pos: 0, big: true }, } } - fn value(&mut self, t: Scalar) -> Result { + pub(crate) fn value(&mut self, t: Scalar) -> Result { match self { Rows::Binary { b, pos, big } => { let v = b.get(*pos..).and_then(|r| t.read(r, *big)).ok_or_else(|| invalid("the file ends early"))?; @@ -265,7 +272,7 @@ impl<'a> Rows<'a> { } } } - fn count(&mut self, t: Scalar) -> Result { + pub(crate) fn count(&mut self, t: Scalar) -> Result { let n = self.value(t)?; if !(n >= 0.0 && n <= MAX_FACE as f64 && n.fract() == 0.0) { return Err(invalid("a list is longer than SolidCraft reads")); @@ -316,7 +323,6 @@ pub(crate) type Vertex = ([f64; 3], Option<[u8; 3]>); /// into `faces` (a fan of triangles per face) when given. struct Read { vertices: u64, - colored: bool, bad_faces: usize, } @@ -327,7 +333,7 @@ fn walk(bytes: &[u8], keep: &mut dyn FnMut(u64) -> bool, on_vertex: &mut dyn FnM if w.xyz.is_none() { return Err(invalid("the vertices have no `x`, `y` and `z`")); } - let mut out = Read { vertices: vertex.count, colored: w.rgb.is_some(), bad_faces: 0 }; + let mut out = Read { vertices: vertex.count, bad_faces: 0 }; let body = bytes.len().saturating_sub(h.body) as u64; let mut rows = Rows::new(&h, bytes); let mut slots = vec![0.0f64; MAX_PROPERTIES]; @@ -447,14 +453,6 @@ fn walk(bytes: &[u8], keep: &mut dyn FnMut(u64) -> bool, on_vertex: &mut dyn FnM Ok(out) } -/// The vertices of a PLY file, every `stride`th one (`1`: all), and how many the file has. -/// The colour of each is `Some` when the file has colours. -pub(crate) fn read_vertices(bytes: &[u8], stride: u64, on_vertex: &mut dyn FnMut(Vertex)) -> Result<(u64, bool)> { - let stride = stride.max(1); - let r = walk(bytes, &mut |i| i.is_multiple_of(stride), on_vertex, None)?; - Ok((r.vertices, r.colored)) -} - /// How many vertices and faces a PLY file says it has (from its header alone). pub fn ply_counts(bytes: &[u8]) -> Result<(u64, u64)> { let h = header(bytes)?; @@ -501,10 +499,12 @@ pub fn read_ply_mesh(bytes: &[u8], name: &str) -> Result<(Vec, Vec Result<(Vec, u64, bool)> { - let mut out = Vec::new(); - let (n, c) = read_vertices(bytes, stride, &mut |v| out.push(v))?; - Ok((out, n, c)) + /// The vertices kept from at most `max` of them, how many the file has, and whether it has colours. + fn vertices(bytes: &[u8], max: usize) -> Result<(Vec, u64, bool)> { + let r = crate::ply_complex::read_ply_cloud(bytes, max)?; + let colors = r.colors.as_ref(); + let out = r.positions.iter().enumerate().map(|(i, p)| (*p, colors.and_then(|c| c.get(i).copied()))).collect(); + Ok((out, r.vertices, colors.is_some())) } fn binary(big: bool) -> Vec { @@ -532,7 +532,7 @@ mod tests { #[test] fn binary_little_and_big_endian_with_colours_and_other_elements() { for big in [false, true] { - let (v, n, colored) = vertices(&binary(big), 1).unwrap(); + let (v, n, colored) = vertices(&binary(big), 3).unwrap(); assert_eq!((n, colored), (3, true)); assert_eq!(v[1], ([1.5, 0.0, 0.0], Some([10, 20, 255]))); assert_eq!(v[2].0, [0.0, 2.5, -3.0]); @@ -551,7 +551,7 @@ mod tests { let src = "ply\nformat ascii 1.0\nelement vertex 4\nproperty double x\nproperty double y\nproperty double z\nproperty float red\nproperty float green\nproperty float blue\n\ element face 1\nproperty list uchar uint vertex_index\nend_header\n\ 0 0 0 1 0 0\n1 0 0 0 1 0\n1 1 0 0 0 1\n0 1 0 0.5 0.5 0.5\n4 0 1 2 3\n"; - let (v, n, colored) = vertices(src.as_bytes(), 1).unwrap(); + let (v, n, colored) = vertices(src.as_bytes(), 4).unwrap(); assert_eq!((n, colored, v.len()), (4, true, 4)); assert_eq!(v[0].1, Some([255, 0, 0])); assert_eq!(v[3], ([0.0, 1.0, 0.0], Some([128, 128, 128]))); @@ -560,7 +560,7 @@ mod tests { // Only points (as the glim maps are written): no colours, no faces. let points = "ply\nformat ascii 1.0\nelement vertex 2\nproperty float32 x\nproperty float32 y\nproperty float32 z\nend_header\n1 2 3\n4 5 6\n"; - let (v, n, colored) = vertices(points.as_bytes(), 1).unwrap(); + let (v, n, colored) = vertices(points.as_bytes(), 2).unwrap(); assert_eq!((v.len(), n, colored), (2, 2, false)); assert_eq!(v[1], ([4.0, 5.0, 6.0], None)); assert!(read_ply_mesh(points.as_bytes(), "x").is_err(), "a point cloud is not a mesh"); @@ -589,13 +589,13 @@ mod tests { for c in &cases { let r = std::panic::catch_unwind(|| { let _ = vertices(c, 1); - let _ = vertices(c, 3); + let _ = vertices(c, 1000); let _ = read_ply_mesh(c, "x"); let _ = ply_counts(c); }); assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(c)); } - assert!(vertices(&good[..good.len() - 30], 1).is_err(), "a cut-off file is refused"); + assert!(vertices(&good[..good.len() - 30], 1000).is_err(), "a cut-off file is refused"); // A face naming a vertex that isn't there is left out, with a warning. let tri = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 2\nproperty list uchar int vertex_indices\nend_header\n0 0 0 1 0 0 0 1 0\n3 0 1 2\n3 0 1 99\n"; let (mesh, warnings) = read_ply_mesh(tri.as_bytes(), "x").unwrap(); diff --git a/crates/io/src/ply_complex.rs b/crates/io/src/ply_complex.rs new file mode 100644 index 0000000000..ff8c4c290d --- /dev/null +++ b/crates/io/src/ply_complex.rs @@ -0,0 +1,147 @@ +//! A PLY file read with everything a point cloud can hold: its vertices with all their +//! properties. Positions are `x`, `y`, `z` and colours `red`, `green`, `blue`; every other scalar +//! property becomes a column named as in the file, whole-numbered when its type is an integer. A +//! comment `property: 0 name, 1 name` names the values of a whole-numbered property. Every +//! `stride`th vertex is kept. + +use solidcraft_doc::point_cloud::{Column, Complex, Floats, MAX_COLUMNS, Names}; + +use crate::ply::{Element, Kind, Rows, Scalar, header}; +use crate::{IoError, Result}; + +fn invalid(m: impl std::fmt::Display) -> IoError { + IoError::Invalid(format!("PLY: {m}")) +} + +/// What a PLY file holds, positions in the file's units. +pub(crate) struct PlyCloud { + pub positions: Vec<[f64; 3]>, + pub colors: Option>, + pub complex: Complex, + /// Vertices in the file. + pub vertices: u64, +} + +fn integer(s: Scalar) -> bool { + !matches!(s, Scalar::F32 | Scalar::F64) +} + +fn named(e: &Element, names: &[&str]) -> Option { + names.iter().find_map(|n| e.properties.iter().position(|p| p.name.eq_ignore_ascii_case(n) && matches!(p.kind, Kind::Scalar(_)))) +} + +/// The columns an element's scalar properties fill, leaving out those at `taken`. +fn columns_of(e: &Element, taken: &[usize]) -> Vec<(usize, Column)> { + e.properties + .iter() + .enumerate() + .filter(|(i, _)| !taken.contains(i)) + .filter_map(|(i, p)| match p.kind { + Kind::Scalar(s) => Some((i, Column { name: p.name.clone(), integer: integer(s), values: Floats::default() })), + Kind::List(..) => None, + }) + .collect() +} + +/// Read a PLY file's vertices, every `stride`th one of those beyond `max_points`. +pub(crate) fn read_ply_cloud(bytes: &[u8], max_points: usize) -> Result { + let h = header(bytes)?; + let vertex = h.elements.iter().find(|e| e.name == "vertex").ok_or_else(|| invalid("no `vertex` element"))?; + let stride = vertex.count.div_ceil(max_points.max(1) as u64).max(1); + let xyz = [named(vertex, &["x"]), named(vertex, &["y"]), named(vertex, &["z"])]; + let [Some(x), Some(y), Some(z)] = xyz else { return Err(invalid("the vertices have no `x`, `y` and `z`")) }; + let rgb = + [named(vertex, &["red", "r", "diffuse_red"]), named(vertex, &["green", "g", "diffuse_green"]), named(vertex, &["blue", "b", "diffuse_blue"])]; + let rgb = match rgb { + [Some(r), Some(g), Some(b)] => Some([r, g, b]), + _ => None, + }; + let mut taken = vec![x, y, z]; + taken.extend(rgb.into_iter().flatten()); + let mine = columns_of(vertex, &taken); + if mine.len() > MAX_COLUMNS { + return Err(invalid(format!("more than {MAX_COLUMNS} properties"))); + } + let body = bytes.len().saturating_sub(h.body) as u64; + let mut rows = Rows::new(&h, bytes); + let mut out = PlyCloud { positions: Vec::new(), colors: rgb.map(|_| Vec::new()), complex: Complex::default(), vertices: vertex.count }; + let mut columns: Vec<(usize, Vec, Column)> = mine.into_iter().map(|(i, c)| (i, Vec::new(), c)).collect(); + let mut row = vec![0.0f64; vertex.properties.len()]; + // The elements before the vertices are read past. + for e in h.elements.iter().take_while(|e| e.name != "vertex").chain([vertex]) { + if e.count > body { + return Err(invalid(format!("the file is too short for its {} {}", e.count, e.name))); + } + let vertices = std::ptr::eq(e, vertex); + for r in 0..e.count { + for (i, p) in e.properties.iter().enumerate() { + match p.kind { + Kind::Scalar(t) => { + let v = rows.value(t)?; + if let Some(s) = row.get_mut(i).filter(|_| vertices) { + *s = v; + } + } + Kind::List(c, t) => { + for _ in 0..rows.count(c)? { + rows.value(t)?; + } + } + } + } + if !vertices || !r.is_multiple_of(stride) { + continue; + } + let v = |i: usize| row.get(i).copied().unwrap_or(0.0); + out.positions.push([v(x), v(y), v(z)]); + if let (Some(c), Some(ks)) = (out.colors.as_mut(), rgb) { + let t = |i: usize| match vertex.properties.get(i).map(|p| p.kind) { + Some(Kind::Scalar(s)) => s.channel(v(i)), + _ => 0, + }; + c.push(ks.map(t)); + } + for (slot, values, _) in &mut columns { + values.push(v(*slot) as f32); + } + } + } + let columns: Vec = columns.into_iter().map(|(_, values, c)| Column { values: Floats(values.into()), ..c }).collect(); + // Names only for whole-numbered properties the file has, the first comment for each. + let mut names: Vec = Vec::new(); + for n in h.comments.iter().filter_map(|c| Names::parse(c)) { + let label = columns.iter().any(|c| c.name == n.property && c.integer); + if label && !names.iter().any(|m| m.property == n.property) && names.len() < MAX_COLUMNS { + names.push(n); + } + } + out.complex = Complex { columns, names }; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_vertex_property_is_kept() { + let text = "ply\nformat ascii 1.0\ncomment role: 7 wall, -1 none\ncomment t: seconds since the start\ncomment width: 0 thin\n\ + element vertex 4\nproperty float x\nproperty float y\nproperty float z\nproperty double t\nproperty int role\nproperty float width\n\ + element face 1\nproperty list uchar int vertex_indices\nend_header\n\ + 0 0 0 0.5 7 1\n1 0 0 1.5 7 1\n1 1 0 2.5 -1 2\n0 1 0 3.5 -1 2\n4 0 1 2 3\n"; + let r = read_ply_cloud(text.as_bytes(), 10).unwrap(); + assert_eq!(r.positions.len(), 4); + let col = |n: &str| r.complex.column(n).unwrap(); + assert_eq!(&*col("t").values.0, &[0.5, 1.5, 2.5, 3.5]); + assert!(col("role").integer && !col("width").integer); + assert_eq!(&*col("role").values.0, &[7.0, 7.0, -1.0, -1.0]); + r.complex.check(4).unwrap(); + // Names for the whole-numbered `role` only: `t` is no list and `width` is not whole. + assert_eq!(r.complex.names.len(), 1); + assert_eq!(r.complex.names("role").and_then(|n| n.name(-1)), Some("none")); + // Every second vertex, with its numbers. + let half = read_ply_cloud(text.as_bytes(), 2).unwrap(); + assert_eq!((half.positions.len(), &*half.complex.column("t").unwrap().values.0), (2, &[0.5f32, 2.5][..])); + half.complex.check(2).unwrap(); + } +} diff --git a/crates/io/src/point_cloud.rs b/crates/io/src/point_cloud.rs index 3790fa9b8b..02fb59620b 100644 --- a/crates/io/src/point_cloud.rs +++ b/crates/io/src/point_cloud.rs @@ -34,6 +34,8 @@ pub struct CloudFile { pub colors: Vec, /// Points in the file. pub source_points: u64, + /// The file's other properties of the points (PLY). + pub complex: solidcraft_doc::point_cloud::Complex, pub warnings: Vec, } @@ -77,20 +79,19 @@ pub fn read_point_cloud(bytes: &[u8], file: &str, scale: f64, max_points: usize) let ply = crate::ply::is_ply(bytes) || (is_ply_path(file) && !bytes.is_empty()); let stride; if ply { - let (count, _) = crate::ply::ply_counts(bytes)?; - stride = count.div_ceil(max as u64).max(1); - keep.out.points.reserve(count.div_ceil(stride).min(max as u64) as usize * 3); - // Colours are known once the header is read: the first vertex says. - let mut first = true; - let (count, colored) = crate::ply::read_vertices(bytes, stride, &mut |(p, rgb)| { - if first { - keep.colored = rgb.is_some(); - first = false; - } - keep.push(p, rgb); - })?; - keep.out.source_points = count; - keep.colored &= colored; + let r = crate::ply_complex::read_ply_cloud(bytes, max)?; + stride = r.vertices.div_ceil(r.positions.len().max(1) as u64).max(1); + keep.colored = r.colors.is_some(); + let colors = r.colors.unwrap_or_default(); + for (i, p) in r.positions.iter().enumerate() { + keep.push(*p, colors.get(i).copied()); + } + // A point left out would shift every number after it. + if keep.bad > 0 && !r.complex.is_empty() { + return Err(IoError::Invalid(format!("{} point(s) are not finite numbers within range", keep.bad))); + } + keep.out.source_points = r.vertices; + keep.out.complex = r.complex; } else { stride = read_xyz(bytes, &mut keep)?; } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 4bc2fcd1a3..2ad8d52eca 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -35,7 +35,7 @@ pub trait Backend { None } /// How calls are checked against their commands' typed contracts (`--param-check`): by the - /// headless session, or by `Remote` before it sends them to the app. + /// headless session, or for `Remote` by the app or before the call is sent to it. fn param_check(&self) -> ParamCheck { ParamCheck::Off } @@ -112,6 +112,11 @@ pub(crate) struct Waits { const WAITS: Waits = Waits { pending: PENDING_WAIT, settle: SETTLE_WAIT, poll: POLL_EVERY }; +/// The level a control reply says its request was checked at (#9184), if it says one. +fn echoed(v: &Value) -> Option { + v.get("param_check").and_then(Value::as_str).and_then(ParamCheck::parse) +} + /// A control reply as a call's result. fn outcome(v: &Value) -> Result { if v.get("ok").and_then(Value::as_bool) == Some(true) { @@ -136,8 +141,14 @@ pub struct Remote { /// An outstanding request's end, once a later call waited for it (for `pending`). settled: Option, waits: Waits, - /// How calls are checked before they are sent (`--param-check`). + /// How calls are checked (`--param-check`): by the app, when it checks a request's own + /// level, else here before they are sent. param_check: ParamCheck, + /// Whether the connected app checks a request's `param_check` itself (#9184), asked once per + /// connection; `None` until asked. + app_checks: Option, + /// The level the app said it checked the last reply at (`param_check` beside `ok`). + echoed: Option, } /// How a tool error starts when no app listens at the bridged address. The agent reads it, and @@ -173,9 +184,18 @@ impl Remote { settled: None, waits: WAITS, param_check: ParamCheck::Off, + app_checks: None, + echoed: None, } } + /// Check calls against their commands' contracts at `level` (`--param-check`): an app that + /// checks a request's own level is sent it, and an older one gets only the calls that pass. + pub fn with_param_check(mut self, level: ParamCheck) -> Self { + self.param_check = level; + self + } + /// Shorter waits for requests still running (tests). #[cfg(test)] pub(crate) fn set_waits(&mut self, waits: Waits) { @@ -185,6 +205,8 @@ impl Remote { /// Close the connection. A request still running loses its reply with it. fn drop_conn(&mut self) { self.conn = None; + // The next connection may reach another app (restarted, another version). + self.app_checks = None; if let Some(o) = self.outstanding.take() { self.settled = Some(Settled::Lost { what: o.what }); } @@ -407,6 +429,7 @@ impl Remote { if method != "control.running" { self.settle_earlier()?; } + self.echoed = None; let what = control_pending::describe(method, ¶ms); let effect = control_pending::effect(method, ¶ms); let again = retry_busy.then(|| params.clone()); @@ -424,6 +447,7 @@ impl Remote { } .map_err(|e| self.explain(&e))?; let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?; + self.echoed = echoed(&v); // Timed out in the app, still running (#1618): wait for its result on this connection. if v.get("running").and_then(Value::as_bool) == Some(true) { self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent }); @@ -433,7 +457,10 @@ impl Remote { self.waits.pending.as_secs() ); return match self.wait_outstanding(self.waits.pending)? { - Waited::Done(reply) => outcome(&reply), + Waited::Done(reply) => { + self.echoed = echoed(&reply); + outcome(&reply) + } Waited::Nothing => { Err(format!("{what} timed out in the app, which kept no result for it: inspect the design before sending it again")) } @@ -457,35 +484,82 @@ impl Remote { } } -/// The issues `params` raise against the typed contract of `id` in this server's registry; none -/// when it has no contract for `id` (the app may have commands this server lacks). +/// What is wrong with `params` for `id` by this server's contracts, as messages; none when it has +/// no contract for `id` (the app may have commands this server lacks). fn contract_issues(id: &str, params: &Value) -> Vec { - let contract = solidcraft_engine::find_command(id).and_then(|c| c.contract); - contract.map(|c| c.check(params).into_iter().map(|i| i.message).collect()).unwrap_or_default() + solidcraft_engine::schema::issues(id, params).into_iter().map(|i| i.message).collect() } -/// `reply` with `issues` as its `param_warnings`, as a headless session at the warn level adds -/// them; a reply that has its own (an app that checked the call itself) keeps those. -fn with_param_warnings(mut reply: Value, issues: Vec) -> Value { +/// Give `reply` `issues` as its `param_warnings`, as a headless session at the warn level does; a +/// reply that has its own (an app that checked the call itself) keeps those. +fn add_param_warnings(reply: &mut Value, issues: Vec) { if !issues.is_empty() && let Some(o) = reply.as_object_mut() { o.entry("param_warnings").or_insert_with(|| json!(issues)); } - reply } impl Remote { - /// Check calls against their commands' contracts at `level` before sending them - /// (`--param-check`): the app's own session checks nothing. - pub fn with_param_check(mut self, level: ParamCheck) -> Self { - self.param_check = level; - self + /// Whether the app checks a request's `param_check` itself (#9184): asked once per + /// connection with `control.capabilities`, which lists the levels it takes; an older app + /// answers "unknown method". Any other answer (no app, a busy one) is asked again next time. + fn app_checks(&mut self) -> bool { + if let Some(known) = self.app_checks { + return known; + } + // Sent ahead of settling a request still running on this connection: the app answers + // `control.capabilities` at once, as `control.running`, without taking that one's reply. + let line = self.request_line("control.capabilities", json!({})); + let reply = self.roundtrip(&line).ok().and_then(|r| serde_json::from_str::(&r).ok()); + let known = match reply.as_ref().map(outcome) { + Some(Ok(c)) => Some(c["param_check"].as_array().is_some_and(|levels| levels.iter().any(|l| l == self.param_check.name()))), + Some(Err(e)) if e.starts_with("unknown method") => Some(false), + _ => None, + }; + if self.conn.is_some() { + self.app_checks = known; + } + known.unwrap_or(false) } - /// `engine.execute` checked here: `strict` refuses a call that breaks its command's contract - /// without sending it, and `warn` sends it and adds the issues to the app's reply. + /// The reply to a call sent with this bridge's level: at `strict`, one that doesn't say the + /// app checked it at that level is an error, since the call may have run unchecked; and the + /// next calls are checked here. + fn confirmed(&mut self, reply: Result) -> Result { + let unconfirmed = reply.is_ok() && self.echoed != Some(self.param_check); + if unconfirmed { + self.app_checks = Some(false); + } + match reply { + Ok(_) if unconfirmed && self.param_check == ParamCheck::Strict => { + Err("the app did not say it checked this call at --param-check strict, so it may have run unchecked: inspect the design \ + before sending it again (the next calls are checked here before they are sent)" + .into()) + } + other => other, + } + } + + /// `params` with this bridge's level as the request's `param_check`, for an app that checks it. + fn at_level(&self, mut params: Value) -> Value { + if let Some(o) = params.as_object_mut() { + o.insert("param_check".into(), json!(self.param_check.name())); + } + params + } + + /// `engine.execute` checked at this bridge's level. An app that checks a request's level is + /// sent it, and checks against its own commands (its reply must say so, see `confirmed`). + /// For an older one the check is made here: + /// `strict` refuses a call that breaks its command's contract without sending it, and `warn` + /// sends it and adds the issues to the app's reply. fn execute_checked(&mut self, params: Value) -> Result { + if self.app_checks() { + let params = self.at_level(params); + let reply = self.call_once("engine.execute", params, true); + return self.confirmed(reply); + } let id = params.get("command").and_then(Value::as_str).unwrap_or_default(); let issues = contract_issues(id, params.get("params").unwrap_or(&Value::Null)); if self.param_check == ParamCheck::Strict @@ -493,14 +567,21 @@ impl Remote { { return Err(solidcraft_engine::params::bad(id, first.as_str()).to_string()); } - let reply = self.call_once("engine.execute", params, true)?; - Ok(with_param_warnings(reply, issues)) + let mut reply = self.call_once("engine.execute", params, true)?; + add_param_warnings(&mut reply, issues); + Ok(reply) } - /// `engine.batch` checked here: `strict` refuses a batch with a step that breaks its command's - /// contract before anything is sent, as the app would refuse it unrun, and `warn` adds each - /// step's issues to its result. + /// `engine.batch` checked at this bridge's level, as `execute_checked` checks a call: by an + /// app that checks a request's level, else here, where `strict` refuses a batch with a step + /// that breaks its command's contract before anything is sent, as the app would refuse it + /// unrun, and `warn` adds each step's issues to its result. fn batch_checked(&mut self, params: Value) -> Result { + if self.app_checks() { + let params = self.at_level(params); + let reply = self.call_once("engine.batch", params, true); + return self.confirmed(reply); + } let steps = params.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); if self.param_check == ParamCheck::Strict { if let Some(refusal) = solidcraft_engine::batch_preflight::contracts(steps) { @@ -518,7 +599,7 @@ impl Remote { let mut reply = self.call_once("engine.batch", params, true)?; if let Some(results) = reply.get_mut("results").and_then(Value::as_array_mut) { for (result, issues) in results.iter_mut().zip(issues) { - *result = with_param_warnings(std::mem::take(result), issues); + add_param_warnings(result, issues); } } Ok(reply) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index a9883270cd..6c897500ec 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -2374,6 +2374,70 @@ fn a_bridged_server_checks_calls_at_its_param_check_level() { assert_eq!(r.get("param_warnings"), None, "{r}"); } +/// Bridged to an app that checks a request's own `param_check` (#9184), the server asks it once +/// per connection (`control.capabilities`) and then sends every call with its level, unchecked +/// here: the app checks against its own commands, so a key only a newer app knows is not +/// refused, and its answer is the one the agent sees. +#[test] +fn a_bridged_server_leaves_the_check_to_an_app_that_makes_it() { + use solidcraft_engine::schema::ParamCheck; + let levels = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let log = levels.clone(); + let (addr, seen) = scripted_app(move |req| { + let level = req["params"]["param_check"].clone(); + match req["method"].as_str() { + Some("control.capabilities") => json!({"ok": true, "result": {"param_check": ["off", "warn", "strict"]}}), + Some("engine.batch") => { + log.lock().unwrap().push(level.clone()); + json!({"ok": true, "result": {"completed": 1, "results": [{"param_warnings": ["the app's own"]}], "undone": 0}, "param_check": level}) + } + _ => { + log.lock().unwrap().push(level.clone()); + json!({"ok": true, "result": {"created_bodies": ["Box1"], "param_warnings": ["the app's own"]}, "param_check": level}) + } + } + }); + let remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap().with_param_check(ParamCheck::Strict); + let mut s = Server::new(Box::new(remote)); + let sent = |what: &str| seen.lock().unwrap().1.iter().filter(|w| w.as_str() == what).count(); + let newer = json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "a_newer_key": 1}}); + + for _ in 0..2 { + let r = tool(&mut s, "execute", newer.clone()); + assert_eq!(r["param_warnings"], json!(["the app's own"]), "{r}"); + } + assert_eq!((sent("engine.execute solid.box"), sent("control.capabilities")), (2, 1), "asked once, both sent"); + // A batch goes on a fresh connection, so the next call asks again. + let r = tool(&mut s, "batch", json!({"commands": [newer.clone()]})); + assert_eq!(r["results"][0]["param_warnings"], json!(["the app's own"]), "{r}"); + tool(&mut s, "execute", newer); + assert_eq!(sent("control.capabilities"), 2); + assert!(levels.lock().unwrap().iter().all(|l| l == "strict"), "{:?}", levels.lock().unwrap()); +} + +/// An app that says it checks `param_check` but answers a call without saying it checked it at +/// the bridge's level (#9184), with no echo or another level: at `strict` that is an error, as the +/// call may have run unchecked, and from then on the server checks the calls itself. +#[test] +fn a_bridged_server_at_strict_refuses_an_unconfirmed_reply() { + use solidcraft_engine::schema::ParamCheck; + for echo in [Value::Null, json!("off")] { + let (addr, seen) = scripted_app(move |req| match req["method"].as_str() { + Some("control.capabilities") => json!({"ok": true, "result": {"param_check": ["off", "warn", "strict"]}}), + _ => json!({"ok": true, "result": {"created_bodies": ["Box1"]}, "param_check": echo}), + }); + let remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap().with_param_check(ParamCheck::Strict); + let mut s = Server::new(Box::new(remote)); + let sent = |what: &str| seen.lock().unwrap().1.iter().filter(|w| w.as_str() == what).count(); + let r = raw_tool(&mut s, "execute", json!({"command": "solid.sphere", "params": {"radius": 2}})); + assert_eq!(r["result"]["isError"], json!(true), "{r}"); + assert!(r.to_string().contains("did not say it checked this call"), "{r}"); + let r = raw_tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}})); + assert!(r.to_string().contains("unknown key `operaton`"), "checked here now: {r}"); + assert_eq!(sent("engine.execute solid.box"), 0, "refused unsent"); + } +} + /// Plain `mcp` with no app running: the session is headless, says so on every result, and bridges /// to the app once one runs, but only while it holds no work of its own. #[test] diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index f87407d48f..bc32aacdea 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -502,6 +502,28 @@ impl Camera { let r = (b.diagonal() * 0.5).max(1.0); self.distance = if self.fov > 0.0 { r / (self.fov * 0.5).sin() * 1.1 } else { r / 0.4 * 1.15 }; } + /// [`Camera::fit`] for a picture of `aspect` (width over height). The fit frames the + /// bounding sphere by the picture's height; a picture taller than wide also needs it inside + /// the sides, so the camera moves back by the ratio (the orthographic width grows with the + /// distance; in perspective the sphere's angular radius shrinks with it, which the fit's + /// margin covers). A wide picture, or an aspect that is not a positive number, keeps the + /// plain fit. Returns false, leaving the camera alone, when there is nothing to fit or the + /// fitted camera would not be valid (a distance that overflows). + pub fn fit_aspect(&mut self, b: &Aabb3, aspect: f64) -> bool { + if b.is_empty() { + return false; + } + let mut fitted = *self; + fitted.fit(b); + if aspect.is_finite() && aspect > 0.0 && aspect < 1.0 { + fitted.distance /= aspect; + } + if !fitted.is_valid() { + return false; + } + *self = fitted; + true + } /// The same camera looking from direction `back` (target → eye). Straight up or down /// keeps the yaw, rounded to the nearest quarter turn so the view stays square. pub fn looking_from(&self, back: Vec3) -> Camera { @@ -863,6 +885,20 @@ mod tests { } } + /// A fit whose distance would overflow (a subnormal aspect, a huge product) is refused + /// and leaves the finite camera as it was. + #[test] + fn fit_aspect_refuses_an_overflowing_fit() { + let before = Camera { distance: 80.0, ..Default::default() }; + let small = Aabb3 { min: Vec3::ZERO, max: Vec3::new(40.0, 30.0, 20.0) }; + let huge = Aabb3 { min: Vec3::new(-1e307, -1e307, -1e307), max: Vec3::new(1e307, 1e307, 1e307) }; + for (b, aspect) in [(&small, 1e-320), (&huge, 1e-6)] { + let mut c = before; + assert!(!c.fit_aspect(b, aspect), "aspect {aspect}"); + assert_eq!(c, before); + } + } + #[test] fn fit_and_hostile() { let mut c = Camera::default(); diff --git a/crates/render/src/job/cpu_budget.rs b/crates/render/src/job/cpu_budget.rs index fa3cd6469d..0494419f8d 100644 --- a/crates/render/src/job/cpu_budget.rs +++ b/crates/render/src/job/cpu_budget.rs @@ -104,17 +104,54 @@ impl CpuBudget { Ok(()) } + /// The smallest profile that admits this image, to name in a refusal. It is never + /// selected automatically: the person chooses it. `None` when no profile admits it. + pub fn smallest_admitting(width: u32, height: u32, progressive: Option) -> Option { + Self::ALL.into_iter().find(|budget| budget.admits_image(width, height, progressive)) + } + + /// What `validate_image` accepts, without building its message (which names a profile). + fn admits_image(self, width: u32, height: u32, progressive: Option) -> bool { + let (w, h) = (u64::from(width), u64::from(height)); + let limits = self.image_limits(progressive.is_some()); + let Some(pixels) = w.checked_mul(h) else { return false }; + w > 0 + && h > 0 + && w <= limits.max_dimension + && h <= limits.max_dimension + && pixels <= limits.max_pixels + && progressive.is_none_or(|options| { + let samples = options.samples_per_pixel; + (1..=self.limits().max_samples_per_pixel).contains(&samples) + && pixels.checked_mul(u64::from(samples)).is_some_and(|n| n <= self.limits().max_camera_samples) + }) + } + + /// The way out of a size refusal, for English messages: the profile that can render + /// the image, or only a smaller size when none can. + pub fn size_advice(width: u32, height: u32, progressive: Option) -> String { + match Self::smallest_admitting(width, height, progressive) { + Some(budget) => format!("choose the {budget:?} CPU resource profile, or a smaller size"), + None => "no CPU resource profile accepts this size; choose a smaller size".into(), + } + } + /// No geometry reads or allocations. This also validates offline saved intent. pub fn validate_image(self, width: u32, height: u32, progressive: Option) -> Result<()> { let (w, h) = (u64::from(width), u64::from(height)); let limits = self.image_limits(progressive.is_some()); let pixels = w.checked_mul(h).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU pixel count overflow"))?; - if w == 0 || h == 0 || w > limits.max_dimension || h > limits.max_dimension || pixels > limits.max_pixels { + if w == 0 || h == 0 { + return Err(JobError::new(ErrorCode::LimitExceeded, format!("CPU {self:?} accepts dimensions 1..{}", limits.max_dimension))); + } + if w > limits.max_dimension || h > limits.max_dimension || pixels > limits.max_pixels { return Err(JobError::new( ErrorCode::LimitExceeded, format!( - "CPU {self:?} accepts dimensions 1..{} and at most {} pixels; select a larger resource profile explicitly", - limits.max_dimension, limits.max_pixels + "CPU {self:?} accepts dimensions 1..{} and at most {} pixels; {}", + limits.max_dimension, + limits.max_pixels, + Self::size_advice(width, height, progressive) ), )); } @@ -173,4 +210,39 @@ mod tests { assert!(CpuBudget::Workstation.validate_image(8192, 8192, p).is_err()); assert!(CpuBudget::Workstation.validate_image(3840, 2160, p).is_ok()); } + + /// #8941: a size refusal names the smallest profile that admits the image, and none when + /// even Workstation refuses it. The profile is only named, never selected. + #[test] + fn a_size_refusal_names_the_smallest_profile_that_admits_it() { + assert_eq!(CpuBudget::smallest_admitting(1920, 1080, None), Some(CpuBudget::Compact)); + assert_eq!(CpuBudget::smallest_admitting(3840, 2160, None), Some(CpuBudget::Studio)); + assert_eq!(CpuBudget::smallest_admitting(15360, 4320, None), Some(CpuBudget::Workstation)); + assert_eq!(CpuBudget::smallest_admitting(16385, 1, None), None); + assert_eq!(CpuBudget::smallest_admitting(0, 1, None), None); + let message = CpuBudget::Compact.validate_image(3840, 2160, None).unwrap_err().to_string(); + assert!(message.contains("choose the Studio CPU resource profile, or a smaller size"), "{message}"); + let message = CpuBudget::Studio.validate_image(15360, 4320, None).unwrap_err().to_string(); + assert!(message.contains("choose the Workstation CPU resource profile"), "{message}"); + let message = CpuBudget::Workstation.validate_image(16384, 16384, None).unwrap_err().to_string(); + assert!(message.contains("no CPU resource profile accepts this size"), "{message}"); + assert!(!message.contains("Studio") && !message.contains("choose the"), "{message}"); + // The message-free check agrees with validate_image everywhere, progressive included. + let sizes = [(0, 1), (1, 1), (2048, 2048), (3840, 2160), (4096, 4096), (7680, 4320), (8192, 8192), (16384, 4096), (u32::MAX, 2)]; + for budget in CpuBudget::ALL { + for progressive in [ + None, + Some(CpuProgressiveOptions { samples_per_pixel: 64, seed: 0 }), + Some(CpuProgressiveOptions { samples_per_pixel: 4096, seed: 0 }), + ] { + for (w, h) in sizes { + assert_eq!( + budget.admits_image(w, h, progressive), + budget.validate_image(w, h, progressive).is_ok(), + "{budget:?} {w}x{h} {progressive:?}" + ); + } + } + } + } } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index c86b5ff1ab..6621a6263f 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -65,8 +65,8 @@ pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRole pub use pattern::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind}; pub use periodic::periodic_beziers; pub use profiles::{ - CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves, - without_text_counters, + CurvePick, MAX_PROFILE_EDGES, Profile, ProfileSearch, distinct_curves, find_drawn_profiles, find_profiles, find_profiles_checked, merge_regions, + profile_at_point, profile_by_curves, without_text_counters, }; pub use shape::{Shape, intersections, intersections_within, nearest_crossing, nearest_curve_intersection}; pub use solver::solve_after_edit_with_analysis; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index dbdb8cc5fc..54bedf4840 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -895,6 +895,7 @@ impl Sketch { } // A resized face can enumerate the same boundary in another order. Match the // shared-point graph in normalized sketch space before discarding its identity. + let mut same_order_agrees = false; if let Some(cur) = &cur && points.len() == lay.pts.len() && cur.len() == lay.curves.len() @@ -1035,11 +1036,34 @@ impl Sketch { } return Ok(false); } + // A face resized by more than the match above allows (#9001: an angle's wall made + // thicker moves the inner corner by more than a tenth of the face) is still the same + // boundary when two witnesses agree: the new boundary was read in the same order + // with the same curves, and each point read in that order is, among the points on + // the same loop with lines the same way, clearly the nearest to where it was. + same_order_agrees = same + && loop_roles.as_ref().is_some_and(|roles| { + (0..points.len()).all(|k| { + let (Some(&fresh_i), Some(p)) = (order.get(k), a.get(k)) else { return false }; + let mut candidates: Vec<(usize, f64)> = b + .iter() + .enumerate() + .filter(|(i, _)| { + old_roles.get(k) == fresh_roles.get(*i) + && roles.old.get(k) == roles.fresh.get(*i) + && roles.old_lines.get(k).zip(roles.fresh_lines.get(*i)).is_some_and(|(x, y)| loops::same_lines(x, y)) + }) + .map(|(i, q)| (i, p.dist(*q))) + .collect(); + candidates.sort_by(|x, y| x.1.total_cmp(&y.1)); + matches!(candidates.first(), Some((i, d)) if *i == fresh_i && candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.75)) + }) + }); } // Equal topology alone does not identify a face boundary: an outer rim and a // hole can have identical point graphs. Never move saved curve ids between them // when the geometric match is ambiguous; the document reports a lost link. - if same && self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) { + if same && !same_order_agrees && self.link(id).is_some_and(|l| matches!(l.source, LinkSource::Face { .. })) { self.set_link_lost(id, true); return Err(SketchError::Invalid("projected face boundary identity is ambiguous; last geometry retained".into())); } diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 4d35ea7e91..4a6da1d79b 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -8,6 +8,7 @@ //! rectangle with a hole, and the disc. use std::f64::consts::TAU; +use std::sync::Arc; use serde::{Deserialize, Serialize}; use solidcraft_geom::{Loop2, Region2, Ring, Seg2, Vec2}; @@ -64,6 +65,8 @@ struct Edge { coverage: Option, /// A piece of a line, arc or circle (not one sampled from a free-form curve). analytic: bool, + /// Index of the sketch curve the piece is part of. + curve: usize, } /// Do two circles lie within `tol` of each other all the way round? @@ -460,7 +463,9 @@ fn close_line_joints(mut segs: Vec, tol: f64) -> Vec { segs } -fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mut [usize], max_edges: usize) { +/// Joins line and arc ends that lie close together. Returns false when there were too many ends +/// close together to look at them all (nothing is joined then). +fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mut [usize], max_edges: usize) -> bool { let tol = ENDPOINT_JOIN_TOL; let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64); let mut curve_endpoints = std::collections::HashMap::>::new(); @@ -531,6 +536,7 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu } } } + !exceeded_limit } /// Where the end of run `a` meets the start of run `b`, near `p`: the point and the parameters @@ -693,15 +699,362 @@ pub fn without_text_counters(sk: &Sketch, profiles: Vec) -> Vec Vec { - find_profiles_bounded(sk, MAX_EDGES) + find_profiles_checked(sk).profiles } +/// The profiles of a sketch, and whether some were possibly left out. +#[derive(Clone, Debug, Default)] +pub struct ProfileSearch { + pub profiles: Vec, + /// A group of touching curves was too large to search completely (more than + /// [`MAX_PROFILE_EDGES`] pieces), so some of its profiles may be missing or not split where + /// curves cross. The caller should say so rather than drop profiles silently. + pub incomplete: bool, +} + +/// Pieces one group of touching curves may be cut into before its profile search stops. +pub const MAX_PROFILE_EDGES: usize = MAX_EDGES; + +/// [`find_profiles`], also telling whether the search was cut short. +/// +/// The search runs island by island: curves whose (slightly enlarged) bounds do not touch any +/// curve of another group cannot cut, join or overlap it, so each group's loops are found on +/// their own, and remembered by a fingerprint of the group's geometry. Dragging one letter of a +/// large imported logo then traces only that letter again (#6896). The loops are nested into +/// profiles over the whole sketch afterwards, so a contour inside another one without touching +/// it still becomes its hole, and the profiles, their order and their curve ids are the ones a +/// single search over the whole sketch finds. +pub fn find_profiles_checked(sk: &Sketch) -> ProfileSearch { + search(sk, MAX_EDGES, true, true) +} + +#[cfg(test)] fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { + search(sk, max_edges, true, true).profiles +} + +fn search(sk: &Sketch, max_edges: usize, split: bool, cached: bool) -> ProfileSearch { + let curves: Vec = sk.curves.iter().enumerate().filter(|(_, c)| !c.construction && !c.centerline && !c.infinite).map(|(i, _)| i).collect(); + let groups = if split { islands(sk, &curves) } else { vec![curves] }; + let found: Vec> = groups + .iter() + .map(|group| { + if !cached { + return Arc::new(island_loops(sk, group, max_edges)); + } + let key = fingerprint(sk, group, max_edges); + if let Some(hit) = island_cache::get(key) { + return hit; + } + let island = Arc::new(island_loops(sk, group, max_edges)); + island_cache::put(key, &island); + island + }) + .collect(); + ProfileSearch { incomplete: found.iter().any(|i| i.incomplete), profiles: assemble(sk, &found) } +} + +/// One island's closed loops, and whether its search was cut short. +struct Island { + loops: Vec, + incomplete: bool, +} + +struct IslandLoop { + lp: Loop2, + ids: Vec, + /// Connected component within the island (loops of one component never nest). + comp: usize, + complete: Option>, + /// Place in the order a search over the whole sketch finds loops: (0, circle curve, 0) for a + /// whole circle, (1, curve of the first edge, first half-edge) for a traced face. + key: (u8, usize, usize), + bounds: solidcraft_geom::Aabb2, + area: f64, +} + +impl IslandLoop { + fn new(lp: Loop2, ids: Vec, comp: usize, complete: Option>, key: (u8, usize, usize)) -> IslandLoop { + let bounds = lp.polyline(1e-3).bounds(); + let area = lp.signed_area().abs(); + IslandLoop { lp, ids, comp, complete, key, bounds, area } + } + + /// Rough size, for bounding the cache. + fn weight(&self) -> usize { + self.lp.segs.len() + self.ids.len() + 1 + } +} + +/// How far apart two curves' pieces must be to search them separately: well past every +/// tolerance the search uses (end joins at `ENDPOINT_JOIN_TOL`, crossings next to an end within +/// `100 * tol` = 1e-3, node merging at `tol`). +const ISLAND_MARGIN: f64 = 2e-3; +/// Box pairs looked at when splitting into islands before giving up and searching the sketch +/// as one island (many long curves lying across each other). +const ISLAND_PAIR_BUDGET: usize = 4_000_000; + +/// Groups of profile curves (each in sketch order, groups by their first curve) that can be +/// searched separately: the boxes of their pieces, enlarged by [`ISLAND_MARGIN`], touch no box +/// of another group. Every box holds everything the search derives from its curve (the end +/// points, and the sampled points of free-form segments, which lie in their control polygon). +fn islands(sk: &Sketch, curves: &[usize]) -> Vec> { + let mut boxes: Vec<(usize, solidcraft_geom::Aabb2)> = Vec::new(); + for (k, &ci) in curves.iter().enumerate() { + for b in piece_boxes(sk, ci) { + if !(b.min.is_finite() && b.max.is_finite()) { + return vec![curves.to_vec()]; + } + boxes.push((k, b.inflated(ISLAND_MARGIN))); + } + } + // Sweep along x: a box meets only boxes still open when it starts. + boxes.sort_by(|a, b| a.1.min.x.total_cmp(&b.1.min.x)); + let mut parent: Vec = (0..curves.len()).collect(); + let mut open: Vec = Vec::new(); + let mut pairs = 0usize; + for (i, (k, b)) in boxes.iter().enumerate() { + open.retain(|&j| boxes.get(j).is_some_and(|(_, o)| o.max.x >= b.min.x)); + pairs += open.len(); + if pairs > ISLAND_PAIR_BUDGET { + return vec![curves.to_vec()]; + } + for &j in &open { + if let Some((kj, o)) = boxes.get(j) + && o.meets(b) + { + union(&mut parent, *k, *kj); + } + } + open.push(i); + } + let mut group_of: std::collections::HashMap = std::collections::HashMap::new(); + let mut groups: Vec> = Vec::new(); + for (k, &ci) in curves.iter().enumerate() { + let root = find(&mut parent, k); + let g = *group_of.entry(root).or_insert_with(|| { + groups.push(Vec::new()); + groups.len() - 1 + }); + if let Some(group) = groups.get_mut(g) { + group.push(ci); + } + } + groups +} + +/// Boxes covering a profile curve: its end points, and each of its pieces (lines and arcs in a +/// few parts, so a long diagonal line or a large circle around other curves does not cover them). +fn piece_boxes(sk: &Sketch, ci: usize) -> Vec { + let Some(c) = sk.curves.get(ci) else { return Vec::new() }; + let mut out = Vec::new(); + let point_box = |p: Vec2| solidcraft_geom::Aabb2 { min: p, max: p }; + if let Some((a, b)) = c.kind.ends() { + out.extend([a, b].into_iter().filter_map(|i| sk.point(i)).map(point_box)); + } + match c.kind { + CurveKind::Line { a, b } => { + if let (Some(a), Some(b)) = (sk.point(a), sk.point(b)) { + seg_boxes(&Seg2::Line { a, b }, &mut out); + } + } + CurveKind::Circle { c, r } => { + if let Some(c) = sk.point(c) { + seg_boxes(&Seg2::Arc { center: c, radius: r, start: 0.0, sweep: TAU }, &mut out); + } + } + CurveKind::Arc { .. } => { + for s in sk.segs(ci).iter().take(1) { + seg_boxes(s, &mut out); + } + } + _ => match sk.exact_segs(ci) { + Some(ex) => ex.iter().for_each(|s| seg_boxes(s, &mut out)), + None => sk.segs(ci).iter().for_each(|s| seg_boxes(s, &mut out)), + }, + } + out +} + +/// Boxes covering a segment and everything the search samples from it. +fn seg_boxes(s: &Seg2, out: &mut Vec) { + const PARTS: usize = 16; + let hull = |pts: &[Vec2]| { + pts.iter().fold(solidcraft_geom::Aabb2::EMPTY, |mut b, p| { + b.add(*p); + b + }) + }; + match *s { + Seg2::Line { a, b } => { + for k in 0..PARTS { + let (t0, t1) = (k as f64 / PARTS as f64, (k + 1) as f64 / PARTS as f64); + out.push(hull(&[a + (b - a) * t0, a + (b - a) * t1])); + } + } + Seg2::Arc { center, radius, start, sweep } => { + // Parts of at most TAU / PARTS; each lies within its chord's box grown by its sagitta. + let r = radius.abs(); + let n = ((sweep.abs() / (TAU / PARTS as f64)).ceil() as usize).clamp(1, PARTS); + let step = sweep / n as f64; + let sag = r * (1.0 - (step.abs() * 0.5).cos()) + r * 1e-12; + let at = |t: f64| center + Vec2::new(t.cos(), t.sin()) * r; + for k in 0..n { + let (t0, t1) = (start + step * k as f64, start + step * (k + 1) as f64); + out.push(hull(&[at(t0), at(t1)]).inflated(sag)); + } + } + Seg2::Cubic { p0, p1, p2, p3 } => out.push(hull(&[p0, p1, p2, p3])), + Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => out.push(hull(&[a, apex, b])), + Seg2::Bezier { n, ref p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE).contains(&(n as usize)) => { + out.push(hull(p.get(..=n as usize).unwrap_or(p))); + } + // Anything else: the very points the search samples. + _ => out.push(hull(&s.polyline(1e-3))), + } +} + +/// A fingerprint of everything [`island_loops`] reads for a group of curves: their places in +/// the sketch, ids, kinds, end points and geometry. +fn fingerprint(sk: &Sketch, curves: &[usize], max_edges: usize) -> u128 { + use std::hash::{Hash, Hasher}; + let mut h = [std::collections::hash_map::DefaultHasher::new(), std::collections::hash_map::DefaultHasher::new()]; + 1u8.hash(&mut h[1]); + let mut put = |f: &dyn Fn(&mut std::collections::hash_map::DefaultHasher)| h.iter_mut().for_each(f); + put(&|x| (max_edges, curves.len()).hash(x)); + let pt = |x: &mut std::collections::hash_map::DefaultHasher, p: Option| match p { + Some(p) => (1u8, p.x.to_bits(), p.y.to_bits()).hash(x), + None => 0u8.hash(x), + }; + for &ci in curves { + let Some(c) = sk.curves.get(ci) else { + put(&|x| (ci, u8::MAX).hash(x)); + continue; + }; + put(&|x| (ci, &c.id, c.construction, c.centerline, c.infinite, std::mem::discriminant(&c.kind)).hash(x)); + if let Some((a, b)) = c.kind.ends() { + put(&|x| { + (a, b).hash(x); + pt(x, sk.point(a)); + pt(x, sk.point(b)); + }); + } + let segs: Vec = match c.kind { + CurveKind::Line { .. } => Vec::new(), + CurveKind::Circle { c, r } => { + put(&|x| { + pt(x, sk.point(c)); + r.to_bits().hash(x); + }); + Vec::new() + } + CurveKind::Arc { .. } => sk.segs(ci).into_iter().take(1).collect(), + _ => match sk.exact_segs(ci) { + Some(ex) => { + put(&|x| 1u8.hash(x)); + ex + } + None => { + put(&|x| 0u8.hash(x)); + sk.segs(ci) + } + }, + }; + for s in &segs { + put(&|x| hash_seg(s, x)); + } + } + let [a, b] = h; + (u128::from(a.finish()) << 64) | u128::from(b.finish()) +} + +fn hash_seg(s: &Seg2, x: &mut impl std::hash::Hasher) { + use std::hash::Hash; + let v = |x: &mut dyn std::hash::Hasher, p: Vec2| { + x.write_u64(p.x.to_bits()); + x.write_u64(p.y.to_bits()); + }; + match *s { + Seg2::Line { a, b } => { + 0u8.hash(x); + v(x, a); + v(x, b); + } + Seg2::Arc { center, radius, start, sweep } => { + 1u8.hash(x); + v(x, center); + (radius.to_bits(), start.to_bits(), sweep.to_bits()).hash(x); + } + Seg2::Cubic { p0, p1, p2, p3 } => { + 2u8.hash(x); + [p0, p1, p2, p3].into_iter().for_each(|p| v(x, p)); + } + Seg2::Conic { a, apex, b, w } => { + 3u8.hash(x); + [a, apex, b].into_iter().for_each(|p| v(x, p)); + w.to_bits().hash(x); + } + Seg2::Bezier { n, ref p } => { + 4u8.hash(x); + n.hash(x); + p.iter().for_each(|q| v(x, *q)); + } + } +} + +/// Island results by fingerprint, shared by every search (live drag frames, evaluation, the +/// profile pickers). Bounded: it is emptied when it grows past its limits. +mod island_cache { + use std::collections::HashMap; + use std::sync::{Arc, Mutex, OnceLock}; + + use super::Island; + + const MAX_ENTRIES: usize = 4096; + const MAX_WEIGHT: usize = 250_000; + + #[derive(Default)] + struct Cache { + map: HashMap>, + weight: usize, + } + + fn cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(Mutex::default) + } + + pub(super) fn get(key: u128) -> Option> { + cache().lock().ok()?.map.get(&key).cloned() + } + + pub(super) fn put(key: u128, island: &Arc) { + let Ok(mut c) = cache().lock() else { return }; + let weight: usize = island.loops.iter().map(super::IslandLoop::weight).sum::() + 1; + if weight > MAX_WEIGHT { + return; + } + if c.map.len() >= MAX_ENTRIES || c.weight.saturating_add(weight) > MAX_WEIGHT { + c.map.clear(); + c.weight = 0; + } + if c.map.insert(key, Arc::clone(island)).is_none() { + c.weight += weight; + } + } +} + +/// The closed loops of one island of profile curves (`curves`, in sketch order), before they +/// are nested into profiles. Everything here only looks at curves of the island: curves of other +/// islands are too far away to cut, join or overlap any of them (see [`islands`]). +fn island_loops(sk: &Sketch, curves: &[usize], max_edges: usize) -> Island { let tol = MERGE_TOL * 10.0; + let mut incomplete = false; let mut endpoint_groups: Vec = (0..sk.points.len()).collect(); let mut curve_endpoints = Vec::new(); - for (curve_index, curve) in sk.curves.iter().enumerate() { - if !curve.construction + for &curve_index in curves { + if let Some(curve) = sk.curves.get(curve_index) + && !curve.construction && !curve.centerline && !curve.infinite && matches!(curve.kind, CurveKind::Line { .. } | CurveKind::Arc { .. }) @@ -710,7 +1063,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { curve_endpoints.extend([(a, curve_index), (b, curve_index)]); } } - merge_nearby_endpoints(sk, &curve_endpoints, &mut endpoint_groups, max_edges); + incomplete |= !merge_nearby_endpoints(sk, &curve_endpoints, &mut endpoint_groups, max_edges); let endpoint_groups: Vec = (0..endpoint_groups.len()).map(|p| find(&mut endpoint_groups, p)).collect(); // Lines, arcs and circles (free-form curves take part as sampled pieces). let analytic = @@ -721,7 +1074,8 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let mut exact: std::collections::HashMap> = std::collections::HashMap::new(); let mut whole_graph_complete = true; let mut expected = std::collections::HashMap::>::new(); - for (ci, c) in sk.curves.iter().enumerate() { + for &ci in curves { + let Some(c) = sk.curves.get(ci) else { continue }; if c.construction || c.centerline || c.infinite { continue; } @@ -778,9 +1132,13 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { tags.resize(shapes.len(), None); if shapes.len() > max_edges { whole_graph_complete = false; + incomplete = true; break; } } + // Past this many shapes the crossings below are not looked for. + let crossings = shapes.len() <= MAX_EDGES; + incomplete |= !crossings; // Split parameters per shape: end points of other curves on it, and crossings. let mut ends: Vec = Vec::new(); for (_, sh) in &shapes { @@ -877,7 +1235,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { // whole circle at most, as two whole circles keep their crossings. let mut carrier: Vec = (0..shapes.len()).collect(); let mut anchors: Vec = Vec::new(); - if shapes.len() <= 50_000 { + if crossings { let mut whole: Vec = shapes.iter().map(|(_, s)| s.is_full()).collect(); for (i, (ci, x)) in shapes.iter().enumerate() { if carrier.get(i) != Some(&i) || !analytic(*ci) { @@ -897,7 +1255,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { } } } - if shapes.len() <= 50_000 { + if crossings { for i in 0..shapes.len() { for &j in partners.get(i).map(Vec::as_slice).unwrap_or(&[]) { if !overlap(i, j) { @@ -927,12 +1285,16 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { // junction of two curves of a loop, #755). Such a crossing is a weak cut. let on = |sh: &Shape, e: Vec2| sh.param_on(e, tol).is_some() || end_param(sh, e, tol).is_some(); let misses = |e: Vec2| (end_param(x, e, tol).is_some() && !on(y, e)) || (end_param(y, e, tol).is_some() && !on(x, e)); + // Every end within reach, whatever the grid's cell size (so an island's + // smaller grid finds the same ends as the whole sketch's). let near_ends = || { - let (cx, cy) = grid.cell(p); - (-1..=1) - .flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy))) - .flat_map(|c| end_cells.get(&c).map(Vec::as_slice).unwrap_or(&[])) - .filter_map(|k| ends.get(*k).copied()) + let reach = solidcraft_geom::Aabb2 { min: p, max: p }.inflated(tol * 100.0); + let cand: Vec = match grid.cells(&reach) { + Some(cs) => cs.iter().flat_map(|c| end_cells.get(c).map(Vec::as_slice).unwrap_or(&[])).copied().collect(), + None => all_ends.clone(), + }; + cand.into_iter() + .filter_map(|k| ends.get(k).copied()) .filter(|e| e.dist(p) < tol * 100.0) .fold((false, false), |(cut, weak), e| if misses(e) { (cut, true) } else { (true, weak) }) }; @@ -996,7 +1358,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { for a in &anchors { node(*a, None); } - let mut loops: Vec<(Loop2, Vec, usize, Option>)> = Vec::new(); // (ccw loop, curve ids, component) + let mut loops: Vec = Vec::new(); let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { let mut ts = cuts.get(k).cloned().unwrap_or_default(); @@ -1051,14 +1413,20 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let last = piece + 2 == ps.len(); let u = node(p, first.then(|| endpoints.map(|(a, _)| a)).flatten()); let v = node(q, last.then(|| endpoints.map(|(_, b)| b)).flatten()); - edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage, analytic }); + edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage, analytic, curve: *ci }); } } } Shape::Round { c, r, start, sweep } => { let full = sweep >= TAU - 1e-12; if full && ts.is_empty() { - loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci, whole_graph_complete.then(|| vec![id.clone()]))); + loops.push(IslandLoop::new( + Loop2::circle(c, r), + vec![id.clone()], + usize::MAX - *ci, + whole_graph_complete.then(|| vec![id.clone()]), + (0, *ci, 0), + )); continue; } let mut bounds: Vec = @@ -1077,7 +1445,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let last = !full && piece + 2 == bounds.len(); let u = node(seg.start(), first.then(|| endpoints.map(|(a, _)| a)).flatten()); let v = node(seg.end(), last.then(|| endpoints.map(|(_, b)| b)).flatten()); - edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage, analytic }); + edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage, analytic, curve: *ci }); } } } @@ -1213,14 +1581,30 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let lp = Loop2 { segs: close_line_joints(restore_exact(segs, &ptags, &exact), ENDPOINT_JOIN_TOL) }; if lp.signed_area() > 1e-9 { let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0); - loops.push((lp, ids, c, whole_graph_complete.then(|| complete_loop(&coverage_tags, &expected, sk)).flatten())); + let curve = edges.get(start / 2).map(|e| e.curve).unwrap_or(usize::MAX); + let complete = whole_graph_complete.then(|| complete_loop(&coverage_tags, &expected, sk)).flatten(); + loops.push(IslandLoop::new(lp, ids, c, complete, (1, curve, start))); } } + Island { loops, incomplete } +} + +/// Nest the loops of every island into profiles: a loop inside a loop of another connected +/// component (from any island) becomes a hole of the innermost one containing it. +fn assemble(sk: &Sketch, islands: &[Arc]) -> Vec { + // The loops in the order one search over the whole sketch finds them, so ties in nesting and + // ordering go the same way: whole circles in sketch order, then the traced faces in the order + // of their first edge (edges follow the curves in sketch order). + let mut all: Vec<((usize, usize), &IslandLoop)> = + islands.iter().enumerate().flat_map(|(k, island)| island.loops.iter().map(move |l| ((k, l.comp), l))).collect(); + all.sort_by_key(|(_, l)| l.key); + let loops: Vec<(&Loop2, &Vec, (usize, usize), &Option>)> = + all.iter().map(|(c, l)| (&l.lp, &l.ids, *c, &l.complete)).collect(); // Nesting: parent = smallest containing loop from another component. Bounding boxes rule // out most pairs before the point-in-loop test. - let areas: Vec = loops.iter().map(|(l, _, _, _)| l.signed_area().abs()).collect(); - let boxes: Vec = loops.iter().map(|(l, _, _, _)| l.polyline(1e-3).bounds()).collect(); + let areas: Vec = all.iter().map(|(_, l)| l.area).collect(); + let boxes: Vec = all.iter().map(|(_, l)| l.bounds).collect(); let inside_box = |j: usize, p: Vec2| boxes.get(j).is_some_and(|b| b.contains(p)); let mut parent_of: Vec> = vec![None; loops.len()]; for (i, (li, _, ci, _)) in loops.iter().enumerate() { @@ -1254,7 +1638,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { .map(|(i, (l, ids, _, complete_outer))| { let kids: Vec = kids_of.get(i).cloned().unwrap_or_default(); let holes: Vec = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _, _)| h.ccw().reversed())).collect(); - let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _, _)| ids.clone())).collect(); + let hole_curves: Vec> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _, _)| (*ids).clone())).collect(); let region = Region2 { outer: l.ccw(), holes }; let area = region.area(); let centroid = region.centroid(); @@ -1262,7 +1646,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { if let Some(holes) = &mut complete_holes { holes.sort(); } - Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid, complete_outer: complete_outer.clone(), complete_holes } + Profile { region, outer_curves: (*ids).clone(), hole_curves, area, centroid, complete_outer: (*complete_outer).clone(), complete_holes } }) .collect(); let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect(); @@ -1617,4 +2001,121 @@ mod tests { assert!(grid.cells(&inf).is_none()); assert!(grid.cells(&huge).is_none()); } + + fn text_into(sk: &mut Sketch, text: &str, at: Vec2, height: f64) { + let source = + crate::LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default(), exprs: Default::default() }; + sk.add_link(crate::LinkKind::Text, source, &crate::text_geometry(text, at, height, 0.0).unwrap()).unwrap(); + } + + fn rectangle(sk: &mut Sketch, x0: f64, y0: f64, x1: f64, y1: f64) { + for (p, q) in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] { + sk.add_line(Vec2::new(p.0, p.1), Vec2::new(q.0, q.1), None, None, None).unwrap(); + } + } + + /// The same profiles, in the same order, with the same curves, holes, geometry and ids. + fn assert_same(got: &[Profile], want: &[Profile]) { + assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want) { + assert_eq!(serde_json::to_string(g).unwrap(), serde_json::to_string(w).unwrap()); + assert_eq!(g.whole_curve_boundaries(), w.whole_curve_boundaries()); + } + } + + /// Searching island by island (and from the cache) finds exactly what one search over the + /// whole sketch finds. Returns the number of islands. + fn islands_match_whole(sk: &Sketch) -> usize { + let whole = search(sk, MAX_EDGES, false, false); + let split = search(sk, MAX_EDGES, true, false); + assert!(!whole.incomplete && !split.incomplete); + assert_same(&split.profiles, &whole.profiles); + for _ in 0..2 { + assert_same(&find_profiles(sk), &whole.profiles); + } + let curves: Vec = (0..sk.curves.len()).collect(); + islands(sk, &curves).len() + } + + /// #6896: letters with counters (holes), a frame round them that touches none (its holes + /// come from other islands), a disc in a counter, a circle crossing two letters, and curves + /// whose ends join or cross next to another curve's end from just apart. + #[test] + fn profiles_found_island_by_island_match_one_whole_search() { + let mut sk = Sketch::new(); + text_into(&mut sk, "B8O", Vec2::new(5.0, 0.0), 10.0); + text_into(&mut sk, "AR", Vec2::new(5.0, -20.0), 10.0); + rectangle(&mut sk, 0.0, -30.0, 60.0, 15.0); + // A disc inside the O's counter, touching nothing: the counter's hole... and a profile. + let disc = sk.add_circle(Vec2::new(28.5, 5.0), 1.0, None, None).unwrap(); + let disc = sk.curves[disc].id.clone(); + // A circle across the A and the R: one island with both. + sk.add_circle(Vec2::new(14.0, -15.0), 4.0, None, None).unwrap(); + // A triangle whose corners miss by 5e-5 (joined), beside a line crossing next to a stray + // end 5e-4 away (the stray end decides whether the crossing cuts). + sk.add_line(Vec2::new(70.0, 0.0), Vec2::new(80.0, 0.0), None, None, None).unwrap(); + sk.add_line(Vec2::new(80.0 + 5e-5, 0.0), Vec2::new(75.0, 8.0), None, None, None).unwrap(); + sk.add_line(Vec2::new(75.0, 8.0 + 5e-5), Vec2::new(70.0, 0.0), None, None, None).unwrap(); + rectangle(&mut sk, 90.0, 0.0, 100.0, 10.0); + sk.add_line(Vec2::new(95.0, -5.0), Vec2::new(95.0, 15.0), None, None, None).unwrap(); + sk.add_line(Vec2::new(95.0 + 5e-4, 10.0 + 5e-4), Vec2::new(105.0, 20.0), None, None, None).unwrap(); + // An arc and a ring far off on their own. + sk.add_arc(Vec2::new(130.0, 0.0), Vec2::new(135.0, 0.0), Vec2::new(125.0, 0.0), [None; 3], None).unwrap(); + sk.add_line(Vec2::new(125.0, 0.0), Vec2::new(135.0, 0.0), None, None, None).unwrap(); + sk.add_circle(Vec2::new(150.0, 0.0), 5.0, None, None).unwrap(); + sk.add_circle(Vec2::new(150.0, 0.0), 3.0, None, None).unwrap(); + let islands = islands_match_whole(&sk); + assert!(islands >= 8, "{islands} islands"); + let profiles = find_profiles(&sk); + // The frame has the letters (and their crossing circle) as holes, across island borders. + let frame = profiles.iter().find(|p| p.outer_curves.len() == 4 && p.region.outer.signed_area() > 2000.0).unwrap(); + assert!(frame.hole_curves.len() >= 4, "{:?}", frame.hole_curves.len()); + // The disc in the O's counter is a hole of the counter, which is its own profile. + assert!(profiles.iter().any(|p| p.hole_curves.iter().any(|h| *h == [disc.clone()]))); + } + + /// #6896: past the edge limit, the old single search dropped profiles silently. The limit + /// now holds per island, so many small islands are all found, and an island too large says + /// so. + #[test] + fn profile_limit_holds_per_island_and_says_when_it_cuts_the_search_short() { + let mut sk = Sketch::new(); + for k in 0..30 { + let x = k as f64 * 20.0; + rectangle(&mut sk, x, 0.0, x + 10.0, 10.0); + } + let whole = search(&sk, 50, false, false); + assert!(whole.incomplete); + assert!(whole.profiles.len() < 30); + let split = search(&sk, 50, true, true); + assert!(!split.incomplete); + assert_eq!(split.profiles.len(), 30); + assert_same(&split.profiles, &search(&sk, MAX_EDGES, false, false).profiles); + // One island past the limit: reported. + let mut big = Sketch::new(); + for k in 0..30 { + let x = k as f64 * 10.0; + rectangle(&mut big, x, 0.0, x + 10.0, 10.0); + } + assert!(search(&big, 50, true, true).incomplete); + assert!(!find_profiles_checked(&big).incomplete); + } + + /// #6896: moving one island's point changes only that island's fingerprint; the result from + /// the cache is the one a fresh whole search finds. + #[test] + fn profiles_from_the_island_cache_follow_a_moved_point() { + let mut sk = Sketch::new(); + text_into(&mut sk, "OB", Vec2::ZERO, 10.0); + rectangle(&mut sk, 30.0, 0.0, 40.0, 10.0); + let curves: Vec = (0..sk.curves.len()).collect(); + let before: Vec = islands(&sk, &curves).iter().map(|g| fingerprint(&sk, g, MAX_EDGES)).collect(); + assert_same(&find_profiles(&sk), &search(&sk, MAX_EDGES, false, false).profiles); + let corner = sk.points.len() - 1; + sk.points[corner].pos = Vec2::new(29.0, 11.0); + let after: Vec = islands(&sk, &curves).iter().map(|g| fingerprint(&sk, g, MAX_EDGES)).collect(); + assert_eq!(before.len(), after.len()); + assert_eq!(before.iter().zip(&after).filter(|(a, b)| a != b).count(), 1); + assert_same(&find_profiles(&sk), &search(&sk, MAX_EDGES, false, false).profiles); + } } diff --git a/crates/ui-egui/src/access.rs b/crates/ui-egui/src/access.rs index 19bdc8ea83..5885905ed8 100644 --- a/crates/ui-egui/src/access.rs +++ b/crates/ui-egui/src/access.rs @@ -22,7 +22,7 @@ pub fn map(app: &SolveApp) -> Option { return Some(m); } let st = app.session.world_state(); - let meshes: Vec<_> = st.bodies.iter().filter(|b| app.session.body_visible(&b.name)).map(|b| b.mesh()).collect(); + let meshes: Vec<_> = st.bodies.iter().filter(|b| app.body_visible(&b.name)).map(|b| b.mesh()).collect(); // Every new map gets a new version (the GPU uploads it once). let version = VERSION.with(|v| { v.set(v.get() + 1); diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs index 092c366162..897f91d40a 100644 --- a/crates/ui-egui/src/agent_chat/acp.rs +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -7,8 +7,119 @@ //! reaches the design through SolidCraft's MCP server instead), and terminal login, so an agent //! that needs one describes a command for the user to run rather than asking for a key. +use serde::Serialize; use serde_json::{Value, json}; +/// One advertised value, with an optional human-readable group label. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigChoice { + pub value: String, + pub name: String, + pub description: String, + pub group: Option, +} +/// A supported ACP select option; identifiers and categories belong to the agent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigOption { + pub id: String, + pub name: String, + pub description: String, + pub category: String, + pub current_value: String, + pub options: Vec, +} + +/// Parse ACP select options as a bounded authoritative snapshot. +#[derive(Debug, PartialEq, Eq)] +pub enum ConfigParse { + Absent, + Invalid, + Snapshot(Vec), +} +pub fn config_options(v: &Value) -> ConfigParse { + let Some(raw_list) = v.get("configOptions") else { + return ConfigParse::Absent; + }; + let Some(list) = raw_list.as_array() else { + return ConfigParse::Invalid; + }; + let mut out = Vec::new(); + let mut ids = std::collections::HashSet::new(); + for x in list.iter().take(64) { + match x.get("type").and_then(Value::as_str) { + Some("select") => {} + Some(_) => continue, + None => return ConfigParse::Invalid, + } + let Some(id) = x.get("id").and_then(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 256) else { + return ConfigParse::Invalid; + }; + let Some(current_value) = x.get("currentValue").and_then(Value::as_str).filter(|s| s.len() <= 256) else { + return ConfigParse::Invalid; + }; + let Some(raw) = x.get("options").and_then(Value::as_array) else { + return ConfigParse::Invalid; + }; + if !ids.insert(id.to_string()) { + continue; + } + let mut choices = Vec::new(); + let mut values = std::collections::HashSet::new(); + 'items: for item in raw.iter().take(128) { + if choices.len() >= 128 { + break; + } + let group = + item.get("name").and_then(Value::as_str).or_else(|| item.get("group").and_then(Value::as_str)).map(|s| s.chars().take(256).collect()); + if let Some(cs) = item.get("options").and_then(Value::as_array) { + for c in cs.iter().take(128) { + if choices.len() >= 128 { + break 'items; + } + let Some(value) = c.get("value").and_then(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 256) else { + continue; + }; + if !values.insert(value.to_string()) { + continue; + } + choices.push(ConfigChoice { + value: value.into(), + name: c.get("name").and_then(Value::as_str).unwrap_or(value).chars().take(256).collect(), + description: c.get("description").and_then(Value::as_str).unwrap_or("").chars().take(1024).collect(), + group: group.clone(), + }); + } + } else { + let Some(value) = item.get("value").and_then(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 256) else { + continue; + }; + if values.insert(value.to_string()) { + choices.push(ConfigChoice { + value: value.into(), + name: item.get("name").and_then(Value::as_str).unwrap_or(value).chars().take(256).collect(), + description: item.get("description").and_then(Value::as_str).unwrap_or("").chars().take(1024).collect(), + group: None, + }); + } + } + } + if choices.is_empty() { + return ConfigParse::Invalid; + } + out.push(ConfigOption { + id: id.into(), + name: x.get("name").and_then(Value::as_str).unwrap_or(id).chars().take(256).collect(), + description: x.get("description").and_then(Value::as_str).unwrap_or("").chars().take(1024).collect(), + category: x.get("category").and_then(Value::as_str).unwrap_or("").chars().take(128).collect(), + current_value: current_value.into(), + options: choices, + }); + } + ConfigParse::Snapshot(out) +} + /// The protocol version we speak. pub const PROTOCOL_VERSION: u64 = 1; @@ -183,6 +294,9 @@ pub fn models(session: &Value) -> Option<(String, Vec)> { pub fn set_model_params(session: &str, model: &str) -> Value { json!({"sessionId": session, "modelId": model}) } +pub fn set_config_option_params(session: &str, id: &str, value: &str) -> Value { + json!({"sessionId": session, "configId": id, "value": value}) +} /// Whether the agent takes embedded documents in a message /// (`agentCapabilities.promptCapabilities.embeddedContext`: it reads `resource` blocks). @@ -307,23 +421,47 @@ pub struct PermissionOption { #[derive(Clone, Debug, PartialEq)] pub enum Incoming { /// The reply to one of our requests. - Reply { id: u64, result: Result }, + Reply { + id: u64, + result: Result, + }, /// Text the agent streams (`thought`: its reasoning, shown dimmed). - Text { thought: bool, text: String }, + Text { + thought: bool, + text: String, + }, /// A new tool call, or news about one (`title`/`status` when they changed). - Tool { id: String, title: Option, kind: Option, status: Option, input: Option, output: Option }, + Tool { + id: String, + title: Option, + kind: Option, + status: Option, + input: Option, + output: Option, + }, /// The agent's plan: each entry with its status. Plan(Vec<(String, String)>), /// The agent asks before running a tool call; answered with [`permission_reply`]. - Permission { rpc: Value, title: String, options: Vec }, + Permission { + rpc: Value, + title: String, + options: Vec, + }, /// The agent says whether it is logged in (an extension both adapters send). AuthStatus(String), /// The agent switched to another of its modes. ModeChanged(String), /// The agent switched to another model. ModelChanged(String), + ConfigOptions { + session: String, + options: Vec, + }, /// A request we don't serve (file system, terminal, …): answer "method not found". - Unsupported { rpc: Value, method: String }, + Unsupported { + rpc: Value, + method: String, + }, /// Anything else (mode and command lists, usage): nothing to show. Ignored, } @@ -399,7 +537,7 @@ pub fn parse(line: &str) -> Option { Some(Incoming::Permission { rpc: id.clone(), title, options }) } (Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }), - (Some("session/update"), None) => Some(update(params.get("update")?)), + (Some("session/update"), None) => Some(update(params.get("update")?, params.get("sessionId").and_then(Value::as_str).unwrap_or(""))), (Some("_auth/status_update"), None) => { params.get("authStatus").and_then(|s| s.get("label")).and_then(Value::as_str).map(|l| Incoming::AuthStatus(l.to_string())) } @@ -407,7 +545,7 @@ pub fn parse(line: &str) -> Option { } } -fn update(u: &Value) -> Incoming { +fn update(u: &Value, session: &str) -> Incoming { let s = |k: &str| u.get(k).and_then(Value::as_str).map(str::to_string); match u.get("sessionUpdate").and_then(Value::as_str) { Some(kind @ ("agent_message_chunk" | "agent_thought_chunk")) => match u.get("content").and_then(content_text) { @@ -434,6 +572,10 @@ fn update(u: &Value) -> Incoming { Some(m) => Incoming::ModelChanged(m), None => Incoming::Ignored, }, + Some("config_option_update") => match config_options(u) { + ConfigParse::Snapshot(options) if !session.is_empty() => Incoming::ConfigOptions { session: session.into(), options }, + _ => Incoming::Ignored, + }, Some("plan") => Incoming::Plan( u.get("entries") .and_then(Value::as_array) @@ -629,7 +771,7 @@ mod tests { fn long_tool_output_is_cut_on_a_character_boundary() { let long = "é".repeat(3000); let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t", "content": [{"type": "content", "content": {"type": "text", "text": long}}]}); - let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() }; + let Incoming::Tool { output: Some(o), .. } = update(&u, "s") else { panic!() }; assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…')); } @@ -650,5 +792,42 @@ mod tests { let changed = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_model_update","currentModelId":"haiku"}}}"#; assert_eq!(parse(changed), Some(Incoming::ModelChanged("haiku".into()))); assert_eq!(set_model_params("s", "opus"), json!({"sessionId": "s", "modelId": "opus"})); + assert_eq!(set_config_option_params("s1", "vendor.model", "smart"), json!({"sessionId":"s1","configId":"vendor.model","value":"smart"})); + } + + #[test] + fn config_options_parse_flat_and_grouped_selects_and_ignore_unsupported() { + let v = json!({"configOptions":[ + {"id":"arbitrary","name":"Model","category":"model","type":"select","currentValue":"a","options":[{"value":"a","name":"A"}]}, + {"id":"effort","category":"thought_level","type":"select","currentValue":"high","options":[{"group":"Reasoning","options":[{"value":"high","name":"High"}]}]}, + {"id":"bad","type":"boolean"}]}); + let ConfigParse::Snapshot(options) = config_options(&v) else { panic!() }; + assert_eq!(options.len(), 2); + assert_eq!(options[0].id, "arbitrary"); + assert_eq!(options[1].options[0].group.as_deref(), Some("Reasoning")); + assert!(matches!( + config_options(&json!({"configOptions":[{"type":"select","id":"broken","currentValue":"x","options":[]}]})), + ConfigParse::Invalid + )); + assert_eq!(config_options(&json!({"configOptions":[]})), ConfigParse::Snapshot(vec![])); + let notification = json!({"sessionUpdate":"config_option_update","configOptions":[]}); + assert_eq!(update(¬ification, "s"), Incoming::ConfigOptions { session: "s".into(), options: vec![] }); + } + + #[test] + fn config_snapshots_bound_choices_and_preserve_unknown_categories() { + let choices: Vec<_> = (0..300).map(|n| json!({"value":n.to_string(),"name":"Choice"})).collect(); + let option = json!({"id":"custom","name":"Custom","type":"select","currentValue":"0","options":[ + {"group":"internal-id","name":"Readable group","options":choices}, + {"value":"extra","name":"Extra"} + ]}); + let ConfigParse::Snapshot(options) = config_options(&json!({"configOptions":[option.clone(),option]})) else { panic!() }; + assert_eq!(options.len(), 1, "duplicate IDs do not produce duplicate controls"); + assert_eq!(options[0].options.len(), 128, "the choice cap spans groups and flat entries"); + assert_eq!(options[0].options[0].group.as_deref(), Some("Readable group")); + assert_eq!(options[0].category, "", "categories are optional metadata"); + assert_eq!(config_options(&json!({})), ConfigParse::Absent); + assert_eq!(config_options(&json!({"configOptions":null})), ConfigParse::Invalid); + assert_eq!(config_options(&json!({"configOptions":[{"type":"select","id":"broken"}]})), ConfigParse::Invalid); } } diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs index d1c91ae1aa..3f0e1eb348 100644 --- a/crates/ui-egui/src/agent_chat/acp_client.rs +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -18,6 +18,7 @@ enum Pending { Authenticate, SetMode, SetModel, + SetConfig, } /// Variables the agent doesn't inherit: a control token set for the app's own `--control` port @@ -39,6 +40,8 @@ pub struct AcpBackend { permissions: HashMap, next_key: u64, exited: bool, + config_pending: Option<(u64, String)>, + modern_config: bool, } impl AcpBackend { @@ -63,6 +66,8 @@ impl AcpBackend { permissions: HashMap::new(), next_key: 0, exited: false, + config_pending: None, + modern_config: false, }; b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))); Ok(b) @@ -75,6 +80,8 @@ 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()); self.request(Pending::SessionNew, "session/new", params); } @@ -94,11 +101,20 @@ impl AcpBackend { (Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) { Some(s) => { self.session = Some(s.to_string()); - if let Some((current, available)) = acp::modes(&v) { - out.push(Event::Modes { current, available }); - } - if let Some((current, available)) = acp::models(&v) { - out.push(Event::Models { current, available }); + match acp::config_options(&v) { + acp::ConfigParse::Snapshot(options) => { + self.modern_config = true; + out.push(Event::ConfigOptions(options)); + } + _ => { + self.modern_config = false; + if let Some((current, available)) = acp::modes(&v) { + out.push(Event::Modes { current, available }); + } + if let Some((current, available)) = acp::models(&v) { + out.push(Event::Models { current, available }); + } + } } out.push(Event::Documents(self.documents)); out.push(Event::Ready { agent: self.agent.clone(), images: self.images }); @@ -112,15 +128,36 @@ impl AcpBackend { // Signed in: try the session again. (Pending::Authenticate, Ok(_)) => self.new_session(), (Pending::SetMode | Pending::SetModel, Ok(_)) => {} + (Pending::SetConfig, Ok(v)) => { + if let Some((request_id, config_id)) = self.config_pending.take() + && request_id == id + { + let result = match acp::config_options(&v) { + acp::ConfigParse::Snapshot(o) => Ok(o), + _ => Err("The agent returned no valid configuration options.".into()), + }; + out.push(Event::ConfigOptionFinished { id: config_id, result }); + } + } (what, Err((code, message))) => { + let config_error = what == Pending::SetConfig; + if what == Pending::SetConfig + && let Some((request_id, config_id)) = self.config_pending.take() + && request_id == id + { + out.push(Event::ConfigOptionFinished { id: config_id, result: Err(message.clone()) }); + } let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth"); if auth { self.session = None; + self.modern_config = false; + self.config_pending = None; + self.pending.retain(|_, request| *request != Pending::SetConfig); if what == Pending::Prompt { out.push(Event::TurnEnded { reason: "auth".into() }); } out.push(Event::AuthRequired { methods: self.auth.clone() }); - } else { + } else if !config_error { if what == Pending::Prompt { out.push(Event::TurnEnded { reason: "error".into() }); } @@ -138,8 +175,12 @@ impl AcpBackend { out.push(Event::Permission { key: self.next_key, title, options }); } Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)), - Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }), - Incoming::ModelChanged(m) => out.push(Event::Models { current: m, available: Vec::new() }), + Incoming::ModeChanged(m) if !self.modern_config => out.push(Event::Modes { current: m, available: Vec::new() }), + Incoming::ModelChanged(m) if !self.modern_config => out.push(Event::Models { current: m, available: Vec::new() }), + Incoming::ConfigOptions { session, options } if self.session.as_deref() == Some(session.as_str()) => { + out.push(Event::ConfigOptions(options)) + } + Incoming::ModeChanged(_) | Incoming::ModelChanged(_) | Incoming::ConfigOptions { .. } => {} Incoming::Unsupported { rpc, method } => { self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`"))); } @@ -165,6 +206,7 @@ impl ChatBackend for AcpBackend { self.exited = true; self.session = None; self.pending.clear(); + self.config_pending = None; out.push(Event::Exited { tail }); break; } @@ -195,6 +237,9 @@ impl ChatBackend for AcpBackend { self.cancel(); } self.session = None; + self.modern_config = false; + self.config_pending = None; + self.pending.retain(|_, request| *request != Pending::SetConfig); self.new_session(); } @@ -220,6 +265,16 @@ impl ChatBackend for AcpBackend { } } + fn set_config_option(&mut self, id: &str, value: &str) { + if self.config_pending.is_some() { + return; + } + if let Some(s) = self.session.clone() { + self.request(Pending::SetConfig, "session/set_config_option", acp::set_config_option_params(&s, id, value)); + self.config_pending = Some((self.next_id, id.to_string())); + } + } + fn pid(&self) -> Option { self.proc.pid() } @@ -271,3 +326,51 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result Value { + json!({"sessionId":session,"configOptions":[{"id":"provider.model","category":"model","name":"Model","type":"select","currentValue":value,"options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}) + } + + #[test] + fn config_updates_and_replies_are_isolated_to_the_active_session() { + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut backend = AcpBackend::start(&["sh".into(), "-c".into(), "cat >/dev/null".into()], ctx).unwrap(); + let mut events = Vec::new(); + backend.handle(Incoming::Reply { id: 1, result: Ok(json!({"agentInfo":{"name":"fake"}})) }, &mut events); + backend.handle(Incoming::Reply { id: 2, result: Ok(snapshot("s1", "fast")) }, &mut events); + assert_eq!(backend.session.as_deref(), Some("s1")); + events.clear(); + backend.set_config_option("provider.model", "smart"); + let old_request = backend.config_pending.as_ref().map(|p| p.0).unwrap(); + ChatBackend::new_session(&mut backend); + let new_session_request = backend.next_id; + assert!(backend.config_pending.is_none()); + let old_notification = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"config_option_update","configOptions":[]}}}"#; + backend.handle(acp::parse(old_notification).unwrap(), &mut events); + assert!(events.is_empty()); + backend.handle(Incoming::Reply { id: new_session_request, result: Ok(snapshot("s2", "fast")) }, &mut events); + assert_eq!(backend.session.as_deref(), Some("s2")); + events.clear(); + backend.set_config_option("provider.model", "smart"); + let new_request = backend.config_pending.as_ref().map(|p| p.0).unwrap(); + backend.handle(Incoming::Reply { id: old_request, result: Ok(snapshot("s1", "smart")) }, &mut events); + backend.handle(Incoming::Reply { id: old_request, result: Err((-32001, "old rejected request".into())) }, &mut events); + backend.handle(acp::parse(old_notification).unwrap(), &mut events); + assert!(events.is_empty(), "old requests and session notifications are ignored"); + assert_eq!(backend.config_pending.as_ref().map(|p| p.0), Some(new_request), "an old reply cannot clear the new session's pending request"); + let new_notification = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s2","update":{"sessionUpdate":"config_option_update","configOptions":[]}}}"#; + backend.handle(acp::parse(new_notification).unwrap(), &mut events); + assert!(matches!(events.as_slice(), [Event::ConfigOptions(options)] if options.is_empty())); + assert_eq!(backend.config_pending.as_ref().map(|p| p.0), Some(new_request), "a notification is not an acknowledgement"); + events.clear(); + backend.handle(Incoming::Reply { id: new_request, result: Ok(snapshot("s2", "smart")) }, &mut events); + assert!(backend.config_pending.is_none()); + assert!(matches!(events.as_slice(), [Event::ConfigOptionFinished { result: Ok(options), .. }] if options[0].current_value == "smart")); + backend.shutdown(true); + } +} diff --git a/crates/ui-egui/src/agent_chat/api_client.rs b/crates/ui-egui/src/agent_chat/api_client.rs index 2c9b57150e..38bc005796 100644 --- a/crates/ui-egui/src/agent_chat/api_client.rs +++ b/crates/ui-egui/src/agent_chat/api_client.rs @@ -30,6 +30,7 @@ const MAX_HISTORY: usize = 240; const MAX_HISTORY_IMAGES: usize = 2; /// What an older view becomes in the history. const DROPPED_VIEW_NOTE: &str = "[an earlier picture of the view, removed to keep the request small]"; +const DROPPED_THINKING_NOTE: &str = "[earlier reasoning, removed with older history]"; /// How long one tool call may take (it may wait for the user's Allow, declined after four /// minutes). It does not depend on the endpoint's request timeout: a tool call is SolidCraft's own /// work and the user's answer, never a wait for the model, so a longer request timeout does not @@ -503,7 +504,7 @@ fn go_reply_from_parts(text: Vec<(bool, String)>, calls: Vec, kept_calls: if !kept_calls.is_empty() { assistant["tool_calls"] = Value::Array(kept_calls); } - Reply { text, calls, assistant } + Reply { text, calls, assistant, stop: "" } } /// Shared text + capped tool-call accumulator for the three Go reply parsers. @@ -876,8 +877,11 @@ impl Worker { fn turn(&mut self, parts: Vec) { self.history.push(user_message(self.cfg.protocol, &parts)); - drop_old_views(&mut self.history, MAX_HISTORY_IMAGES); - self.trim_history(); + let dropped = drop_old_views(&mut self.history, MAX_HISTORY_IMAGES); + let trimmed = self.trim_history(); + if (dropped || trimmed) && self.cfg.protocol == Protocol::Anthropic { + drop_thinking(&mut self.history); + } // No cap on requests: Stop sets `cancel`, checked at the top of every round. loop { // The history as of here is complete (every tool call answered): a cancelled request @@ -910,7 +914,7 @@ impl Worker { } self.history.push(reply.assistant); if reply.calls.is_empty() { - self.emit(Event::TurnEnded { reason: String::new() }); + self.emit(Event::TurnEnded { reason: reply.stop.into() }); return; } // Anthropic takes every result of a round in one user message; OpenAI takes one @@ -964,14 +968,16 @@ impl Worker { } /// Keep the conversation bounded and starting at a user message, so neither protocol sees a - /// reply or a tool result without the message it answers. - fn trim_history(&mut self) { + /// reply or a tool result without the message it answers. True when a message was removed. + fn trim_history(&mut self) -> bool { + let before = self.history.len(); while self.history.len() > MAX_HISTORY { self.history.remove(0); } while self.history.first().is_some_and(|m| !starts_a_turn(m)) { self.history.remove(0); } + self.history.len() != before } fn request(&self) -> Result { @@ -1326,6 +1332,19 @@ struct Reply { /// The assistant message to keep in the history, in the endpoint's own shape. Tool calls are /// capped to the ones the loop answers, so every call kept here gets a result. assistant: Value, + /// Why the answer stopped, as the turn-end reason the chat knows: empty for a normal end, + /// `max_tokens` for an answer cut off at its length limit, `refusal` when the model declined. + stop: &'static str, +} + +/// The turn-end reason for an Anthropic `stop_reason` or an OpenAI `finish_reason`. Anything +/// else, including a value a server made up, is a normal end. +fn stop_reason(reason: Option<&str>) -> &'static str { + match reason { + Some("max_tokens" | "length") => "max_tokens", + Some("refusal" | "content_filter") => "refusal", + _ => "", + } } /// The answer in the endpoint's shape. @@ -1357,8 +1376,16 @@ fn sanitize_reasoning_details(details: Option<&Value>) -> Option { fn openai_reply(body: &Value) -> Reply { let message = body.pointer("/choices/0/message").cloned().unwrap_or(Value::Null); let text = message.get("content").and_then(Value::as_str).filter(|t| !t.is_empty()).map(|t| vec![(false, t.to_string())]).unwrap_or_default(); - // No more than the loop answers: every call kept here must get a `tool` message. - let raw: Vec = message.get("tool_calls").and_then(Value::as_array).map(|a| a.iter().take(32).cloned().collect()).unwrap_or_default(); + let refused = message.get("refusal").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty()); + let stop = if refused { "refusal" } else { stop_reason(body.pointer("/choices/0/finish_reason").and_then(Value::as_str)) }; + // No more than the loop answers: every call kept here must get a `tool` message. A call cut + // off at the length limit is incomplete, so none is run or kept. + let raw: Vec = message + .get("tool_calls") + .and_then(Value::as_array) + .filter(|_| stop != "max_tokens") + .map(|a| a.iter().take(32).cloned().collect()) + .unwrap_or_default(); let calls = raw .iter() .enumerate() @@ -1384,11 +1411,12 @@ fn openai_reply(body: &Value) -> Reply { if !raw.is_empty() { assistant["tool_calls"] = Value::Array(raw); } - Reply { text, calls, assistant } + Reply { text, calls, assistant, stop } } fn anthropic_reply(body: &Value) -> Reply { let content = body.get("content").cloned().unwrap_or(Value::Null); + let stop = stop_reason(body.get("stop_reason").and_then(Value::as_str)); let mut text = Vec::new(); let mut calls = Vec::new(); let assistant = match &content { @@ -1411,6 +1439,8 @@ fn anthropic_reply(body: &Value) -> Reply { } kept.push(block.clone()); } + // A call cut off at the length limit has incomplete input: not run, not kept. + Some("tool_use") if stop == "max_tokens" => {} Some("tool_use") if calls.len() < 32 => { calls.push(Call { id: block.get("id").and_then(Value::as_str).map_or_else(|| format!("toolu-{i}"), str::to_string), @@ -1438,14 +1468,16 @@ fn anthropic_reply(body: &Value) -> Reply { } _ => json!({"role": "assistant", "content": content.clone()}), }; - Reply { text, calls, assistant } + Reply { text, calls, assistant, stop } } /// Keeps the newest `keep` pictures of the view in the history and swaps every older one for a /// short note, so the model still knows a view was shown. Text is never touched. Pictures come -/// as OpenAI `image_url` parts or Anthropic `image` blocks, depending on the endpoint. -fn drop_old_views(history: &mut [Value], keep: usize) { +/// as OpenAI `image_url` parts or Anthropic `image` blocks, depending on the endpoint. True when +/// a picture was swapped. +fn drop_old_views(history: &mut [Value], keep: usize) -> bool { let mut seen = 0; + let mut dropped = false; for msg in history.iter_mut().rev() { let Some(content) = msg.get_mut("content").and_then(Value::as_array_mut) else { continue }; for part in content.iter_mut().rev() { @@ -1455,9 +1487,26 @@ fn drop_old_views(history: &mut [Value], keep: usize) { seen += 1; if seen > keep { *part = json!({"type": "text", "text": DROPPED_VIEW_NOTE}); + dropped = true; } } } + dropped +} + +/// Takes Anthropic thinking blocks out of every earlier reply. Each one is signed against all +/// the conversation before it, so once a dropped view or trimmed message changes that, the API +/// refuses the whole request (#9417); leaving earlier thinking out is allowed. A reply that was +/// only thinking keeps a note, as the API takes no assistant message without content. +fn drop_thinking(history: &mut [Value]) { + for msg in history.iter_mut().filter(|m| m["role"] == "assistant") { + let Some(content) = msg.get_mut("content").and_then(Value::as_array_mut) else { continue }; + let before = content.len(); + content.retain(|b| !matches!(b.get("type").and_then(Value::as_str), Some("thinking" | "redacted_thinking"))); + if content.is_empty() && before > 0 { + content.push(json!({"type": "text", "text": DROPPED_THINKING_NOTE})); + } + } } /// MCP `tools/list` as OpenAI function tools (the hidden ones left out). @@ -1731,12 +1780,12 @@ mod tests { #[test] fn only_the_newest_views_stay_in_the_history_and_older_ones_become_a_note() { let mut h = vec![json!({"role": "system", "content": "note"}), view(1), json!({"role": "assistant", "content": "ok"}), view(2)]; - drop_old_views(&mut h, 2); + assert!(!drop_old_views(&mut h, 2)); assert_eq!(pictures(&h).len(), 2, "two views fit, nothing is dropped"); h.push(json!({"role": "user", "content": "plain text"})); h.push(view(3)); - drop_old_views(&mut h, 2); + assert!(drop_old_views(&mut h, 2)); assert_eq!(pictures(&h), vec!["data:image/png;base64,V2", "data:image/png;base64,V3"]); // The oldest view is a note now; the words sent with it, and every other message, stay. assert_eq!(h[1]["content"][0]["text"], "look 1"); @@ -2649,6 +2698,59 @@ mod tests { assert_eq!(capped.assistant["content"].as_array().unwrap().len(), 32); } + /// Why an answer stopped reaches the turn's end: cut off at the length limit, or declined, + /// in either protocol (#9495). A call cut off mid-way is neither run nor kept in the history. + #[test] + fn answers_say_why_they_stopped() { + let thinking = json!({"type": "thinking", "thinking": "", "signature": "s"}); + let call = json!({"type": "tool_use", "id": "t1", "name": "measure", "input": {}}); + for (reason, stop) in [("end_turn", ""), ("tool_use", ""), ("max_tokens", "max_tokens"), ("refusal", "refusal"), ("made_up", "")] { + assert_eq!(anthropic_reply(&json!({"content": [thinking], "stop_reason": reason})).stop, stop, "{reason}"); + } + assert_eq!(anthropic_reply(&json!({"content": [thinking]})).stop, ""); + let cut = anthropic_reply(&json!({"content": [thinking, call], "stop_reason": "max_tokens"})); + assert!(cut.calls.is_empty()); + assert_eq!(cut.assistant["content"], json!([thinking])); + + let openai = |finish: &str, message: Value| openai_reply(&json!({"choices": [{"message": message, "finish_reason": finish}]})); + let calls = json!([{"id": "c1", "type": "function", "function": {"name": "measure", "arguments": "{\"a\""}}]); + assert_eq!(openai("stop", json!({"content": "Hi"})).stop, ""); + assert_eq!(openai("content_filter", json!({"content": null})).stop, "refusal"); + assert_eq!(openai("stop", json!({"content": null, "refusal": "I can't help with that."})).stop, "refusal"); + let cut = openai("length", json!({"content": null, "tool_calls": calls})); + assert_eq!(cut.stop, "max_tokens"); + assert!(cut.calls.is_empty()); + assert!(cut.assistant.get("tool_calls").is_none()); + } + + /// The loop ends the turn with the reason: an answer that is only thinking, cut off at the + /// length limit, ends as `max_tokens`, not as a normal end with nothing said. + #[test] + fn a_cut_off_answer_ends_the_turn_as_max_tokens() { + let http: HttpFn = Arc::new(move |r: &HttpRequest| { + if r.method == "GET" { + return Ok((200, b"{\"data\":[]}".to_vec())); + } + let body = json!({"content": [{"type": "thinking", "thinking": "", "signature": "s"}], "stop_reason": "max_tokens"}); + Ok((200, body.to_string().into_bytes())) + }); + let cfg = ApiConfig { + name: "Test".into(), + protocol: Protocol::Anthropic, + base_url: "http://localhost:1".into(), + model: "m".into(), + key: Some("sk-secret".into()), + http, + request_timeout_s: 300, + }; + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut b = ApiBackend::start(cfg, ctx).unwrap(); + b.prompt(&[PromptPart::Text("look closer".into())]); + let events = wait_for_turn(&mut b); + b.shutdown(true); + assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason == "max_tokens"), "{events:?}"); + } + #[test] fn anthropic_urls_take_the_base_with_or_without_v1() { assert_eq!(endpoint("https://api.anthropic.com", "messages"), "https://api.anthropic.com/v1/messages"); @@ -2708,6 +2810,66 @@ mod tests { b.shutdown(true); } + /// Thinking blocks are signed against the conversation before them: once an older view is + /// dropped, the earlier ones go too, or Claude refuses the request (#9417). Until then, and + /// for replies after the drop, they are sent back as they came. + #[test] + fn a_dropped_view_takes_earlier_thinking_with_it() { + let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); + let log = seen.clone(); + let http: HttpFn = Arc::new(move |r: &HttpRequest| { + if r.method == "GET" { + return Ok((200, b"{\"data\":[]}".to_vec())); + } + let n = { + let mut l = log.lock().unwrap(); + l.push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap()); + l.len() + }; + let content = if n == 2 { + json!([{"type": "redacted_thinking", "data": "r2"}]) + } else { + json!([{"type": "thinking", "thinking": "", "signature": format!("s{n}")}, {"type": "text", "text": format!("Seen {n}.")}]) + }; + Ok((200, json!({"content": content, "stop_reason": "end_turn"}).to_string().into_bytes())) + }); + let cfg = ApiConfig { + name: "Test".into(), + protocol: Protocol::Anthropic, + base_url: "http://localhost:1".into(), + model: "m".into(), + key: Some("sk-secret".into()), + http, + request_timeout_s: 300, + }; + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut b = ApiBackend::start(cfg, ctx).unwrap(); + for n in 1..=3 { + b.prompt(&[PromptPart::Text(format!("look {n}")), PromptPart::Image { mime: "image/png".into(), base64: format!("V{n}") }]); + wait_for_turn(&mut b); + } + b.prompt(&[PromptPart::Text("and now?".into())]); + wait_for_turn(&mut b); + b.shutdown(true); + let sent = seen.lock().unwrap(); + assert_eq!(sent.len(), 4); + let replies = |i: usize| -> Vec { + sent[i]["messages"].as_array().unwrap().iter().filter(|m| m["role"] == "assistant").map(|m| m["content"].clone()).collect() + }; + // Two views fit: the reply goes back untouched, signature and all. + assert_eq!(replies(1)[0][0]["signature"], "s1"); + // The third view drops the first: no earlier thinking is left, a thinking-only reply + // keeps a note, and the words stay. + let third = replies(2); + assert_eq!(sent[2]["messages"][0]["content"][1]["text"], DROPPED_VIEW_NOTE); + assert_eq!(third[0], json!([{"type": "text", "text": "Seen 1."}])); + assert_eq!(third[1], json!([{"type": "text", "text": DROPPED_THINKING_NOTE}])); + // Nothing is dropped for the next message, so the reply after the drop keeps its thinking. + let fourth = replies(3); + assert_eq!(fourth[..2], third[..]); + assert_eq!(fourth[2][0]["signature"], "s3"); + } + #[test] fn the_anthropic_test_sends_one_small_message_and_checks_the_key_rule() { let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs index 98f5ad2468..afa1c44e9e 100644 --- a/crates/ui-egui/src/agent_chat/backend.rs +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -9,6 +9,7 @@ use serde_json::Value; pub use super::acp::{AuthMethod, PermissionOption, ToolStatus}; +pub use super::acp::{ConfigChoice, ConfigOption}; /// A connection test keeps authentication failures distinct from transport or protocol errors. #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,29 +54,63 @@ pub enum Event { /// Whether SolidCraft tools were offered by the backend. ToolsAvailable(bool), /// Ready for a message. `agent`: its name and version; `images`: it takes pictures. - Ready { agent: String, images: bool }, + Ready { + agent: String, + images: bool, + }, /// Text it streams (`thought`: its reasoning, shown dimmed). - Text { thought: bool, text: String }, + Text { + thought: bool, + text: String, + }, /// A new tool call, or news about one (`input`: its arguments, when the agent says). - Tool { id: String, title: Option, kind: Option, status: Option, input: Option, output: Option }, + Tool { + id: String, + title: Option, + kind: Option, + status: Option, + input: Option, + output: Option, + }, /// Its plan, replaced whole: each entry with its status. Plan(Vec<(String, String)>), /// It asks before running something of its own; answered with [`ChatBackend::answer_permission`]. - Permission { key: u64, title: String, options: Vec }, + Permission { + key: u64, + title: String, + options: Vec, + }, /// It needs the user to sign in; `methods` are its own ways to do so. - AuthRequired { methods: Vec }, + AuthRequired { + methods: Vec, + }, /// What it says about its login. AuthStatus(String), /// Its own modes (an ACP agent's `modes`: ask, accept edits, plan, …) and the current one. - Modes { current: String, available: Vec<(String, String)> }, + Modes { + current: String, + available: Vec<(String, String)>, + }, /// Its models (empty `available`: only the current one changed). - Models { current: String, available: Vec }, + Models { + current: String, + available: Vec, + }, + ConfigOptions(Vec), + ConfigOptionFinished { + id: String, + result: Result, String>, + }, /// The message is answered (`reason`: why it stopped, empty for a normal end). - TurnEnded { reason: String }, + TurnEnded { + reason: String, + }, /// Something failed; the backend is still running unless [`Event::Exited`] follows. Error(String), /// It ended; its last error output. - Exited { tail: Vec }, + Exited { + tail: Vec, + }, } /// What a backend gets to start. @@ -112,6 +147,7 @@ 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) {} + fn set_config_option(&mut self, _id: &str, _value: &str) {} /// Its process, for status and tests. fn pid(&self) -> Option { None diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs index cf9490f1e8..ace05c3c29 100644 --- a/crates/ui-egui/src/agent_chat/discover.rs +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -3,6 +3,7 @@ //! or a session: each agent found is asked for `--version` (with a time limit), and whether it //! looks logged in comes only from whether its own credentials file exists. +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -200,13 +201,20 @@ fn home() -> Option { std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).filter(|p| p.is_absolute()) } -/// PATH, then the usual places npm, Homebrew, nvm, Volta, bun, pipx and Grok put commands. +/// PATH, then the usual places npm, Homebrew, nvm, Volta, bun, pipx and Grok put commands, and on +/// Windows Node's own install folders: a launcher can start SolidCraft with a PATH that lacks +/// them, and an npm agent's `.cmd` shim then can't find `node` (#9297). pub fn search_dirs() -> Vec { - let mut dirs: Vec = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(prefix) = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from) { - dirs.push(if cfg!(windows) { prefix.clone() } else { prefix.join("bin") }); + search_dirs_in(&|name| std::env::var_os(name), cfg!(windows)) +} + +/// [`search_dirs`] in the environment `var` reads, on Windows or not. +fn search_dirs_in(var: &dyn Fn(&str) -> Option, windows: bool) -> Vec { + let mut dirs: Vec = var("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); + if let Some(prefix) = var("NPM_CONFIG_PREFIX").map(PathBuf::from) { + dirs.push(if windows { prefix.clone() } else { prefix.join("bin") }); } - if let Some(h) = home() { + if let Some(h) = var(if windows { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).filter(|p| p.is_absolute()) { for d in [".local/bin", ".npm-global/bin", ".npm-packages/bin", ".volta/bin", ".bun/bin", ".cargo/bin", ".grok/bin", "bin"] { dirs.push(h.join(d)); } @@ -217,10 +225,15 @@ pub fn search_dirs() -> Vec { dirs.extend(v.into_iter().rev()); } } - if cfg!(windows) { - if let Some(a) = std::env::var_os("APPDATA") { + if windows { + if let Some(a) = var("APPDATA") { dirs.push(PathBuf::from(a).join("npm")); } + // Node's installers (64-bit and 32-bit), and nvm-windows' link to the version in use. + for program_files in ["ProgramFiles", "ProgramFiles(x86)"] { + dirs.extend(var(program_files).map(|d| PathBuf::from(d).join("nodejs"))); + } + dirs.extend(var("NVM_SYMLINK").map(PathBuf::from)); } else { for d in ["/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin", "/usr/bin"] { dirs.push(PathBuf::from(d)); @@ -560,6 +573,42 @@ mod tests { assert_eq!(KNOWN.first().map(|k| k.id), Some("claude"), "the Claude Code adapter is the default"); } + /// 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. + #[test] + fn on_windows_nodes_install_folders_are_searched() { + let root = std::env::temp_dir().join(format!("sc-agent-node-{}", std::process::id())); + let system = root.join("Windows"); + let program_files = root.join("Program Files"); + let x86 = root.join("Program Files (x86)"); + let nvm = root.join("nvm4w").join("nodejs"); + let env = |name: &str| -> Option { + match name { + "PATH" => Some(system.clone().into()), + "ProgramFiles" => Some(program_files.clone().into()), + "ProgramFiles(x86)" => Some(x86.clone().into()), + "NVM_SYMLINK" => Some(nvm.clone().into()), + _ => None, + } + }; + let dirs = search_dirs_in(&env, true); + assert_eq!(dirs.first(), Some(&system), "the PATH SolidCraft was given comes first"); + for node in [program_files.join("nodejs"), x86.join("nodejs"), nvm.clone()] { + assert!(dirs.contains(&node), "{} in {dirs:?}", node.display()); + } + std::fs::create_dir_all(program_files.join("nodejs")).unwrap(); + for name in ["node.exe", "node"] { + std::fs::write(program_files.join("nodejs").join(name), "").unwrap(); + } + assert_eq!(find_in("node", std::slice::from_ref(&system)), None, "not on the PATH the launcher gave"); + assert!(find_in("node", &dirs).is_some_and(|p| p.starts_with(program_files.join("nodejs"))), "{dirs:?}"); + // Nothing is made up where the variables are missing, and elsewhere they mean nothing. + assert_eq!(search_dirs_in(&|name| (name == "PATH").then(|| system.clone().into()), true), std::slice::from_ref(&system)); + assert!(!search_dirs_in(&env, false).iter().any(|d| d.ends_with("nodejs"))); + let _ = std::fs::remove_dir_all(root); + } + /// Finder's PATH lacks user-installed runtimes. Discovery, the version probe and a live /// agent must all work with an env shebang whose interpreter is in ~/.local/bin. #[test] diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index 2fd57e3ea1..30fb5b07f5 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -399,6 +399,8 @@ pub struct AgentChat { /// The provider picked in the header when the models come from several providers (`None`: the /// current model's). It only narrows the model list; the agent keeps its model until one is chosen. pub model_provider: Option, + pub config_options: Option>, + pub config_pending: bool, /// An API endpoint's models, kept per endpoint (its base URL) for the next connection. models_cache: std::collections::HashMap>, /// The running API agent's endpoint (empty for other agents). @@ -459,12 +461,41 @@ impl AgentChat { pub(crate) fn disconnect_document(&mut self, session: &mut solidcraft_engine::Session, wait: bool) { finish_turn(self, session); + self.clear_config(); self.stop_backend(wait); if self.state.is_some() { self.state = Some(State::Off); } } + fn clear_config(&mut self) { + if self.config_options.take().is_some() { + self.models.clear(); + self.model.clear(); + self.modes.clear(); + self.mode.clear(); + self.model_provider = None; + } + self.config_pending = false; + } + + fn apply_config_snapshot(&mut self, options: Vec) { + self.config_options = Some(options.clone()); + let model = options.iter().find(|o| o.category == "model"); + self.models = model + .map(|o| { + o.options.iter().map(|x| acp::ModelInfo { id: x.value.clone(), name: x.name.clone(), description: x.description.clone() }).collect() + }) + .unwrap_or_default(); + let current = model.map_or_else(String::new, |o| o.current_value.clone()); + if current != self.model { + self.model_provider = None; + } + self.model = current; + let mode = options.iter().find(|o| o.category == "mode"); + self.modes = mode.map(|o| o.options.iter().map(|x| (x.value.clone(), x.name.clone())).collect()).unwrap_or_default(); + self.mode = mode.map_or_else(String::new, |o| o.current_value.clone()); + } /// Put a file on the next message with an id of its own; its name back. fn add_file(&mut self, file: attach::Attachment) -> String { let id = self.next_file_id(); @@ -610,7 +641,8 @@ impl AgentChat { self.state = Some(State::Ready); self.picking = Some(false); // Keep the mode across a new chat. - if let Some(m) = self.restore_mode.take() + if self.config_options.is_none() + && let Some(m) = self.restore_mode.take() && self.modes.iter().any(|(id, _)| *id == m) && let Some(b) = self.backend.as_mut() { @@ -671,24 +703,41 @@ impl AgentChat { } Event::Permission { key, title, options } => return Some(Outcome::Permission { key, title, options }), Event::Modes { current, available } => { - if !available.is_empty() { + if self.config_options.is_none() && !available.is_empty() { self.modes = available; } - self.mode = current; + if self.config_options.is_none() { + self.mode = current; + } } Event::Models { current, available } => { - if !available.is_empty() { + if self.config_options.is_none() && !available.is_empty() { if !self.running_endpoint.is_empty() { self.models_cache.insert(self.running_endpoint.clone(), available.clone()); } self.models = available; } - if current != self.model { - self.model_provider = None; + if self.config_options.is_none() { + if current != self.model { + self.model_provider = None; + } + self.model = current; + } + } + Event::ConfigOptions(options) => { + self.apply_config_snapshot(options); + } + Event::ConfigOptionFinished { result, .. } => { + self.config_pending = false; + match result { + Ok(options) => { + self.apply_config_snapshot(options); + } + Err(message) => self.notice(message, true), } - self.model = current; } Event::AuthRequired { methods } => { + self.clear_config(); self.auth = methods; self.state = Some(State::NeedsAuth); self.picking = Some(false); @@ -706,8 +755,12 @@ impl AgentChat { if std::mem::take(&mut self.discard_end) { return Some(Outcome::TurnEnd); } - if !matches!(reason.as_str(), "" | "auth" | "error") { - self.notice(format!("({reason})"), false); + match reason.as_str() { + "" | "auth" | "error" => {} + // Not a missing tool: the answer ran out of room, or the model declined (#9495). + "max_tokens" => self.notice(tr("ui.agent_chat_reply_cut_off").to_string(), false), + "refusal" => self.notice(tr("ui.agent_chat_reply_declined").to_string(), false), + _ => self.notice(format!("({reason})"), false), } return Some(Outcome::TurnEnd); } @@ -720,6 +773,7 @@ impl AgentChat { } } Event::Exited { tail } => { + self.clear_config(); self.state = Some(if matches!(self.state(), State::Starting | State::Failed) { State::Failed } else { State::Exited }); self.steering = false; let last: Vec = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); @@ -1232,6 +1286,7 @@ pub fn new_chat(app: &mut SolveApp) { b.new_session(); } } + chat.clear_config(); } /// Whether New chat would throw something away that the user may want (so it asks first). @@ -1745,6 +1800,13 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { let events = app.agent_chat.backend.as_mut().map(|b| b.poll()).unwrap_or_default(); for e in events { let ready = matches!(e, Event::Ready { .. }); + let configured = matches!(e, Event::ConfigOptionFinished { .. }); + let confirmed_model = match &e { + Event::ConfigOptionFinished { id, result: Ok(options) } => { + options.iter().find(|o| o.category == "model").filter(|o| o.id == *id).map(|o| o.current_value.clone()) + } + _ => None, + }; match app.agent_chat.on_event(e) { Some(Outcome::TurnEnd) => { end_turn(app); @@ -1758,11 +1820,27 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options), None => {} } + if let Some(model) = confirmed_model { + app.preferences.agent_models.insert(app.agent_chat.running_id.clone(), model.clone()); + if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == app.agent_chat.running_id && a.kind == agents::Kind::Api) { + a.model = model; + } + } if ready { apply_saved_model(app); } - if ready && let Some((text, image, at, files)) = app.agent_chat.queued.take() { - start_turn(app, text, image, at, files); + if (ready || configured) && !app.agent_chat.config_pending { + if app.agent_chat.config_options.is_some() + && let Some(mode) = app.agent_chat.restore_mode.take() + && mode != app.agent_chat.mode + { + set_mode(app, &mode); + } + if !app.agent_chat.config_pending + && let Some((text, image, at, files)) = app.agent_chat.queued.take() + { + start_turn(app, text, image, at, files); + } } } #[cfg(not(target_arch = "wasm32"))] @@ -1794,6 +1872,14 @@ pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) { /// Choose the model for this chat (the header's model picker): the agent switches to it, and it is /// remembered for this agent; an API endpoint's agent also keeps it as its default model. pub fn set_model(app: &mut SolveApp, model: &str) { + if let Some(options) = app.agent_chat.config_options.as_ref() { + let Some(option) = options.iter().find(|x| x.category == "model") else { + return; + }; + let id = option.id.clone(); + set_config_option(app, &id, model); + return; + } if !app.agent_chat.models.iter().any(|m| m.id == model) { return; } @@ -1902,6 +1988,14 @@ pub fn set_model_provider(app: &mut SolveApp, provider: &str) { /// Switch the agent to one of its own modes (the header's mode picker). pub fn set_mode(app: &mut SolveApp, mode: &str) { + if let Some(options) = app.agent_chat.config_options.as_ref() { + let Some(option) = options.iter().find(|x| x.category == "mode") else { + return; + }; + let id = option.id.clone(); + set_config_option(app, &id, mode); + return; + } if app.agent_chat.modes.iter().any(|(id, _)| id == mode) && let Some(b) = app.agent_chat.backend.as_mut() { @@ -1910,6 +2004,23 @@ pub fn set_mode(app: &mut SolveApp, mode: &str) { } } +/// Change a reported modern ACP configuration option; only acknowledged snapshots change state. +pub fn set_config_option(app: &mut SolveApp, id: &str, value: &str) { + let Some(option) = app.agent_chat.config_options.as_ref().and_then(|xs| xs.iter().find(|x| x.id == id)) else { + return; + }; + if app.agent_chat.config_pending + || !matches!(app.agent_chat.state(), State::Ready | State::Busy) + || !option.options.iter().any(|x| x.value == value) + { + return; + } + if let Some(b) = app.agent_chat.backend.as_mut() { + b.set_config_option(id, value); + app.agent_chat.config_pending = true; + } +} + /// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, /// `connect_at_start` and `open_at_start` (the launch preferences), `open`, /// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the @@ -1997,6 +2108,11 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result Value { let pid = c.backend.as_ref().and_then(|b| b.pid()); json!({ "features": app.preferences.ai_features, + "configOptions": c.config_options, + "configPending": c.config_pending, "toolbarButton": app.preferences.ai_toolbar_button, "connectAtStart": app.preferences.agent_connect_at_start, "openAtStart": app.preferences.agent_chat_open_at_start, diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 88c0fcaf3c..9700911306 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -161,9 +161,9 @@ pub(crate) fn tool_space(app: &SolveApp, mut available: Rect) -> Rect { && let Some(chat) = app.agent_chat.panel_rect.filter(|r| r.is_finite() && r.is_positive()) { if app.preferences.agent_chat_side == "left" { - available.min.x = (chat.right() + crate::shell::SHELL_GAP).clamp(available.left(), available.right()); + available.min.x = crate::shell::within(chat.right() + crate::shell::SHELL_GAP, available.left(), available.right()); } else { - available.max.x = (chat.left() - crate::shell::SHELL_GAP).clamp(available.left(), available.right()); + available.max.x = crate::shell::within(chat.left() - crate::shell::SHELL_GAP, available.left(), available.right()); } } available @@ -847,8 +847,8 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { let frame = Container::new(ui.ctx(), Surface::Viewport).edge_frame(egui::Color32::TRANSPARENT); let composer = Container::new(ui.ctx(), Surface::Viewport).edge(Id::new("sc_agent_chat_composer"), Edge::Bottom); composer.resizable(false).show_separator_line(false).frame(frame).show(ui, |ui| { - ui_kit::assistant_composer_footer(ui, |ui| { - ui_kit::assistant_composer(ui, focused, |ui| { + let footer = ui_kit::assistant_composer_footer(ui, |ui| { + let boxed = ui_kit::assistant_composer(ui, focused, |ui| { // What Pick context chose for this message, each item with an ×. if !app.agent_chat.picked_context.is_empty() { let tray = app.agent_chat.picked_context.clone(); @@ -987,7 +987,9 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { }, ); }); + crate::scenario::publish_rect("agent_chat:composer_box", boxed.response.rect); }); + crate::scenario::publish_rect("agent_chat:composer_footer", footer.response.rect); }); if let Some(at) = remove_picked_at { remove_picked(app, at); @@ -1033,15 +1035,17 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let all = agents::entries(app); let current = agents::current(app); + // An advertised (even empty) config list replaces the older ACP model/mode pickers. + let modern = app.agent_chat.config_options.is_some(); ui.horizontal_wrapped(|ui| { let label = current.as_ref().map_or_else( || tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(), |e| e.name.clone(), ); - let models = models_shown(app); + let models = !modern && models_shown(app); let providers = if models { model_providers(app) } else { Vec::new() }; let w = (ui.available_width() - - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } + - if modern || app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 } - if providers.is_empty() { 0.0 } else { 146.0 }) .clamp(90.0, 260.0); @@ -1082,7 +1086,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { app.agent_chat.agent_id = Some(id); } // The agent's own modes (ACP `modes`): its own tools follow them. - if !app.agent_chat.modes.is_empty() { + if !modern && !app.agent_chat.modes.is_empty() { let modes = app.agent_chat.modes.clone(); let cur = modes.iter().find(|(id, _)| *id == app.agent_chat.mode).map_or_else(|| app.agent_chat.mode.clone(), |(_, n)| n.clone()); let mut pick: Option = None; @@ -1166,6 +1170,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(warning) = settings::model_warning(app) { ui.add(egui::Label::new(RichText::new(warning).color(t.warning).size(t.font_small)).wrap()); } + if let Some(options) = app.agent_chat.config_options.clone() { + config_selects(app, ui, &options); + } ui.horizontal(|ui| { let b = if app.agent_chat.state().running() { ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_stop_tip")) @@ -1222,6 +1229,221 @@ fn picker_width(ui: &egui::Ui, label: &str) -> f32 { text.clamp(t.form_label_width, most) } +/// An agent's select options are authoritative. Compact value pickers share a strip below the +/// agent switcher; only a narrow chat wraps them, rather than spending two full rows per option. +#[cfg(not(target_arch = "wasm32"))] +fn config_selects(app: &mut SolveApp, ui: &mut egui::Ui, options: &[acp::ConfigOption]) { + let t = Tokens::get(); + let pending = app.agent_chat.config_pending; + let strip_width = ui.available_width().min(ui.max_rect().width()).max(0.0); + let mut row = Vec::new(); + let mut used = 0.0; + for option in options { + let current = option.options.iter().find(|choice| choice.value == option.current_value); + let selected = current.map_or(option.current_value.as_str(), |choice| choice.name.as_str()); + let preview = compact_text(selected, 32); + let preferred = ui.painter().layout_no_wrap(preview, egui::FontId::proportional(t.font_label), t.text).size().x + 34.0; + let width = preferred.clamp(76.0, strip_width.clamp(76.0, 200.0)); + let spacing = if row.is_empty() { 0.0 } else { ui.spacing().item_spacing.x }; + if !row.is_empty() && used + spacing + width > strip_width { + ui.horizontal(|ui| { + for (option, width) in row.drain(..) { + config_select(app, ui, option, width, pending); + } + }); + used = 0.0; + } + used += if used == 0.0 { width } else { spacing + width }; + row.push((option, width)); + } + if !row.is_empty() { + ui.horizontal(|ui| { + for (option, width) in row { + config_select(app, ui, option, width, pending); + } + }); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn config_select(app: &mut SolveApp, ui: &mut egui::Ui, option: &acp::ConfigOption, width: f32, pending: bool) { + let t = Tokens::get(); + let current = option.options.iter().find(|choice| choice.value == option.current_value); + let selected = current.map_or(option.current_value.as_str(), |choice| choice.name.as_str()); + // Bound both the measurement and the visible label: agent-provided text may be huge. + // Width follows the current value, leaving short choices room to sit on one line. + let font = egui::FontId::proportional(t.font_label); + let mut chars = 32; + let displayed = loop { + let text = compact_text(selected, chars); + if chars == 1 || ui.painter().layout_no_wrap(text.clone(), font.clone(), t.text).size().x <= width - 36.0 { + break text; + } + chars -= 1; + }; + let name = if option.name.is_empty() { &option.id } else { &option.name }; + let tip = [name.as_str(), option.description.as_str(), current.map_or("", |choice| choice.description.as_str())] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + let mut picked = None; + let response = ui + .add_enabled_ui(!pending && !option.options.is_empty(), |ui| { + ui.allocate_ui(vec2(width, t.control_height), |ui| { + ui_kit::dropdown_menu(ui, ("sc_agent_config", &option.id), &displayed, |ui| { + let mut previous_group: Option<&str> = None; + for choice in &option.options { + let group = choice.group.as_deref().filter(|g| !g.is_empty()); + if let Some(group) = group + && previous_group != Some(group) + { + ui.label(RichText::new(compact_text(group, 32)).color(t.text_dim).size(t.font_small)).on_hover_text(group); + } + previous_group = group; + let row = ui_kit::list_picker(ui, &choice.value, &compact_text(&choice.name, 32), choice.value == option.current_value); + let row = row.on_hover_text( + [choice.name.as_str(), choice.description.as_str()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + ); + crate::scenario::publish_handle(&format!("agent_chat:config:{}:{}", option.id, choice.value), row.rect.center()); + if option.category == "model" { + crate::scenario::publish_handle(&format!("agent_chat:model:{}", choice.value), row.rect.center()); + } + if row.clicked() { + picked = Some(choice.value.clone()); + } + } + }) + }) + .inner + }) + .inner; + crate::scenario::publish_handle(&format!("agent_chat:config:{}", option.id), response.rect.center()); + crate::scenario::publish_rect(&format!("agent_chat:config:{}", option.id), response.rect); + if option.category == "model" { + crate::scenario::publish_handle("agent_chat:model", response.rect.center()); + } else if option.category == "mode" { + crate::scenario::publish_handle("agent_chat:mode", response.rect.center()); + } + response + .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, !pending && !option.options.is_empty(), format!("{name}: {selected}"))); + response.on_hover_text(tip); + if let Some(value) = picked { + super::set_config_option(app, &option.id, &value); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn compact_text(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let short: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { format!("{short}…") } else { short } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod config_ui_tests { + use super::*; + + fn option(id: &str, category: &str, choices: &[(&str, &str, Option<&str>)]) -> acp::ConfigOption { + acp::ConfigOption { + id: id.into(), + name: id.into(), + description: format!("Choose {id}"), + category: category.into(), + current_value: choices.first().map_or("", |choice| choice.0).into(), + options: choices + .iter() + .map(|(value, name, group)| acp::ConfigChoice { + value: (*value).into(), + name: (*name).into(), + description: format!("Description for {name}"), + group: group.map(str::to_owned), + }) + .collect(), + } + } + + fn draw_switcher(app: &mut SolveApp, ctx: &egui::Context, width: f32) { + crate::scenario::clear_handles(); + let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 700.0)); + let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + ui.allocate_ui(vec2(width - 16.0, 600.0), |ui| { + ui_kit::scope(ui, |ui| switcher(app, ui)); + }); + }); + } + + #[test] + fn config_options_share_one_row_when_they_fit_and_wrap_without_legacy_duplicates() { + assert_eq!(compact_text("Énergie très élevée", 7), "Énergie…"); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + // Make legacy provider/model pickers eligible so their absence is meaningful. + app.agent_chat.state = Some(State::Ready); + app.agent_chat.modes = vec![("legacy".into(), "Legacy mode".into())]; + app.agent_chat.models = vec![ + acp::ModelInfo { id: "old".into(), name: "North · Old model".into(), description: String::new() }, + acp::ModelInfo { id: "other".into(), name: "South · Other model".into(), description: String::new() }, + ]; + assert!(models_shown(&app)); + assert_eq!(model_providers(&app).len(), 2); + app.agent_chat.config_options = Some(vec![ + option( + "engine", + "model", + &[("fast", "Énergie très élevée pour une conception détaillée", Some("Standard")), ("large", "Large", Some("Advanced"))], + ), + option("thought", "thought_level", &[("low", "Max", None), ("high", "High", None)]), + option("workflow", "mode", &[("plan", "Build", None)]), + ]); + let ctx = crate::fonts::test_context(); + draw_switcher(&mut app, &ctx, 760.0); + let at = |name| crate::scenario::handle_at(name).expect("config picker handle"); + let agent = at("agent_chat:switcher"); + let model = at("agent_chat:config:engine"); + let effort = at("agent_chat:config:thought"); + let mode = at("agent_chat:config:workflow"); + assert!(agent.y < model.y && (model.y - effort.y).abs() < 2.0 && (effort.y - mode.y).abs() < 2.0); + assert!(model.x < effort.x && effort.x < mode.x && mode.x < 760.0); + assert_eq!(at("agent_chat:model"), model); + assert_eq!(at("agent_chat:mode"), mode); + assert_eq!(crate::scenario::handles("agent_chat:mode").iter().filter(|(name, _)| name == "agent_chat:mode").count(), 1); + assert_eq!(crate::scenario::handles("agent_chat:model").iter().filter(|(name, _)| name == "agent_chat:model").count(), 1); + assert!(crate::scenario::handle_at("agent_chat:provider").is_none()); + assert!(crate::scenario::handle_at("agent_chat:model:old").is_none()); + assert!(crate::scenario::handle_at("agent_chat:model:other").is_none()); + + draw_switcher(&mut app, &ctx, 280.0); + let model = at("agent_chat:config:engine"); + let effort = at("agent_chat:config:thought"); + let mode = at("agent_chat:config:workflow"); + assert!(model.y < effort.y && (effort.y - mode.y).abs() < 2.0, "only the long model needs its own line: {model:?}, {effort:?}, {mode:?}"); + for id in ["engine", "thought", "workflow"] { + let rect = crate::scenario::rect_of(&format!("agent_chat:config:{id}")).expect("config control bounds"); + assert!(rect.left() >= 0.0 && rect.right() <= 280.0, "{id} exceeds the narrow panel: {rect:?}"); + } + + app.agent_chat.config_options = Some(vec![option("engine", "model", &[("fast", "Fast", None)])]); + app.agent_chat.config_pending = true; + draw_switcher(&mut app, &ctx, 280.0); + assert!(crate::scenario::handle_at("agent_chat:config:engine").is_some()); + assert!(crate::scenario::handle_at("agent_chat:config:thought").is_none()); + assert!(crate::scenario::handle_at("agent_chat:config:workflow").is_none()); + assert!(crate::scenario::handle_at("agent_chat:switcher").is_some()); + + app.agent_chat.config_options = Some(vec![]); + draw_switcher(&mut app, &ctx, 280.0); + assert!(crate::scenario::handle_at("agent_chat:switcher").is_some()); + assert!(crate::scenario::handle_at("agent_chat:config:engine").is_none()); + assert!(crate::scenario::handle_at("agent_chat:mode").is_none()); + assert!(crate::scenario::handle_at("agent_chat:provider").is_none()); + assert!(crate::scenario::handle_at("agent_chat:model").is_none()); + } +} + /// 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) { diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index 56828778ec..de6a329915 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -555,6 +555,9 @@ impl ChatBackend for Script { fn answer_permission(&mut self, key: u64, option: Option<&str>) { self.calls.lock().unwrap().push(format!("permission: {key} {option:?}")); } + fn set_config_option(&mut self, id: &str, value: &str) { + self.calls.lock().unwrap().push(format!("set_config_option: {id}={value}")); + } fn authenticate(&mut self, _: &str) {} fn shutdown(&mut self, _: bool) {} } @@ -1233,6 +1236,367 @@ fn hostile_provider_names_are_bounded() { assert_eq!(model_provider(&m("model".into(), "Provider: A\nsecond line • current")).map(|p| p.0), Some("A".into())); } +fn modern_options(model: &str) -> Vec { + vec![ + acp::ConfigOption { + id: "some.provider.model".into(), + name: "Model".into(), + description: String::new(), + category: "model".into(), + current_value: model.into(), + options: ["fast", "smart"] + .into_iter() + .map(|v| acp::ConfigChoice { value: v.into(), name: v.into(), description: String::new(), group: None }) + .collect(), + }, + acp::ConfigOption { + id: "custom-mode-key".into(), + name: "Mode".into(), + description: String::new(), + category: "mode".into(), + current_value: "ask".into(), + options: ["ask", "plan"] + .into_iter() + .map(|v| acp::ConfigChoice { value: v.into(), name: v.into(), description: String::new(), group: None }) + .collect(), + }, + ] +} + +#[test] +fn modern_snapshots_replace_provider_choices_and_ignore_legacy_updates() { + let (mut app, script, ctx) = app_with_script(); + script.report(Event::Models { current: "plain".into(), available: provider_models() }); + poll(&mut app, &ctx); + set_model_provider(&mut app, "Host"); + script.report(Event::ConfigOptions(modern_options("smart"))); + script.report(Event::Models { current: "plain".into(), available: provider_models() }); + script.report(Event::Modes { current: "old-mode".into(), available: vec![("old-mode".into(), "Old mode".into())] }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.agent_chat.mode, "ask"); + assert!(app.agent_chat.model_provider.is_none()); + assert!(model_providers(&app).is_empty()); + assert_eq!(listed_models(&app).into_iter().map(|(m, _)| m.id).collect::>(), ["fast", "smart"]); + script.report(Event::ConfigOptions(vec![])); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.config_options, Some(vec![])); + assert!(app.agent_chat.models.is_empty() && app.agent_chat.modes.is_empty()); + set_model(&mut app, "plain"); + set_mode(&mut app, "old-mode"); + assert!(script.calls().is_empty(), "an empty modern snapshot never falls back to stale legacy choices"); +} + +#[test] +fn modern_acknowledgements_stay_with_their_document_without_resuming_its_queue() { + let (mut app, first, ctx) = app_with_script(); + app.agent_chat.running_id = "first-modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + app.preferences.agent_models.insert("first-modern".into(), "smart".into()); + new_chat(&mut app); + send(&mut app, &ctx, "first request".into()); + first.report(Event::ConfigOptions(modern_options("fast"))); + first.report(Event::Ready { agent: "first-modern".into(), images: false }); + poll(&mut app, &ctx); + assert!(app.agent_chat.config_pending && app.agent_chat.queued.is_some()); + + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + let second = Script::default(); + app.agent_chat.backend = Some(Box::new(second.clone())); + app.agent_chat.state = Some(State::Ready); + app.agent_chat.running_id = "second-modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + first.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert!(app.agent_chat.config_pending, "the hidden tab's acknowledgement cannot unlock this one"); + assert_eq!(app.agent_chat.model, "fast"); + assert!(!app.preferences.agent_models.contains_key("second-modern")); + second.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert_eq!(app.preferences.agent_models.get("second-modern").map(String::as_str), Some("smart")); + + crate::documents::switch(&mut app, 0); + assert!(app.agent_chat.config_pending, "pending configuration belongs to the conversation"); + poll(&mut app, &ctx); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.preferences.agent_models.get("first-modern").map(String::as_str), Some("smart")); + assert_eq!(texts(&app), ["first request"]); + assert!(app.agent_chat.queued.is_none() && app.agent_chat.stopped); + assert!(first.prompts().is_empty() && second.prompts().is_empty(), "a late configuration reply cannot resume a departed tab's prompt"); +} + +#[test] +fn closing_a_pending_modern_chat_preserves_the_surviving_api_endpoint_and_saved_models() { + for close_active in [false, true] { + let (mut app, modern, ctx) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + app.preferences.agent_models.insert("modern".into(), "fast".into()); + set_model(&mut app, "smart"); + assert!(app.agent_chat.config_pending); + + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + let endpoint = "http://127.0.0.1:12345/v1"; + let api = Script::default(); + app.agent_chat.backend = Some(Box::new(api.clone())); + app.agent_chat.state = Some(State::Ready); + app.agent_chat.running_id = "endpoint-agent".into(); + app.agent_chat.running_endpoint = endpoint.into(); + app.preferences.agents.push(agents::AgentConfig { + id: "endpoint-agent".into(), + kind: agents::Kind::Api, + base_url: endpoint.into(), + model: "fast".into(), + ..Default::default() + }); + api.report(models_reply("fast")); + poll(&mut app, &ctx); + set_model(&mut app, "smart"); + let cached = app.agent_chat.models_cache.get(endpoint).cloned().unwrap(); + modern.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + modern.report(Event::ConfigOptions(vec![])); + if close_active { + crate::documents::switch(&mut app, 0); + assert!(app.agent_chat.config_pending); + } + crate::documents::close(&mut app, 0, true); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.running_id, "endpoint-agent"); + assert_eq!(app.agent_chat.running_endpoint, endpoint); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.agent_chat.models_cache.get(endpoint), Some(&cached)); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("fast")); + assert_eq!(app.preferences.agent_models.get("endpoint-agent").map(String::as_str), Some("smart")); + assert_eq!(app.preferences.agents.iter().find(|a| a.id == "endpoint-agent").unwrap().model, "smart"); + assert_eq!(api.calls(), ["set_model: smart"], "the closed chat's events cannot reach the surviving backend"); + + // Reusing the same agent in a fresh design must restore only the last confirmed model. + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + let fresh = Script::default(); + app.agent_chat.backend = Some(Box::new(fresh.clone())); + app.agent_chat.state = Some(State::Starting); + app.agent_chat.running_id = "modern".into(); + fresh.report(Event::ConfigOptions(modern_options("fast"))); + fresh.report(Event::Ready { agent: "modern".into(), images: false }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "fast"); + assert!(!app.agent_chat.config_pending); + assert!(fresh.calls().is_empty(), "an unacknowledged choice from the closed chat is not restored"); + assert_eq!(app.agent_chat.models_cache.get(endpoint), Some(&cached)); + } +} + +#[test] +fn document_disconnect_paths_clear_modern_configuration_and_pending_requests() { + let assert_cleared = |app: &SolveApp| { + assert!(app.agent_chat.backend.is_none()); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + assert!(app.agent_chat.models.is_empty() && app.agent_chat.modes.is_empty()); + assert!(app.agent_chat.model.is_empty() && app.agent_chat.mode.is_empty()); + assert!(app.agent_chat.model_provider.is_none()); + assert_eq!(app.agent_chat.state(), State::Off); + }; + let (mut app, _, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + disconnect(&mut app); + assert_cleared(&app); + + for quitting in [false, true] { + let (mut app, first, ctx) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + app.agent_chat.backend = Some(Box::new(Script::default())); + app.agent_chat.state = Some(State::Ready); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + first.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + if quitting { + shutdown(&mut app); + } else { + app.preferences.ai_features = false; + poll(&mut app, &ctx); + } + assert_cleared(&app); + crate::documents::switch(&mut app, 0); + assert_cleared(&app); + assert!(app.preferences.agent_models.is_empty(), "an unpolled acknowledgement is discarded with its backend"); + } +} + +#[cfg(unix)] +#[test] +fn modern_config_requests_roundtrip_success_rejection_malformed_and_retry() { + let dir = std::env::temp_dir().join(format!("sc-chat-config-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("requests.jsonl"); + 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"}}}\n' "$id" ;; + *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1","configOptions":[{"id":"vendor.model-key","name":"Model","category":"model","type":"select","currentValue":"fast","options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}}\n' "$id" ;; + *'"method":"session/set_config_option"'*) + n=$((n+1)) + case "$n" in + 1|4) v=smart; [ "$n" = 4 ] && v=fast; printf '{"jsonrpc":"2.0","id":%s,"result":{"configOptions":[{"id":"vendor.model-key","name":"Model","category":"model","type":"select","currentValue":"%s","options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}}\n' "$id" "$v" ;; + 2) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32001,"message":"rejected"}}\n' "$id" ;; + 3) printf '{"jsonrpc":"2.0","id":%s,"result":{"configOptions":[{"type":"select"}]}}\n' "$id" ;; + esac ;; + esac +done +"#; + let argv = vec!["sh".into(), "-c".into(), script.into(), "fake".into(), log.to_string_lossy().into_owned()]; + let (wake_tx, wake_rx) = channel(); + let start = backend::StartContext { + cwd: dir.clone(), + bridge: None, + tools: None, + wake: Box::new(move || { + let _ = wake_tx.send(()); + }), + }; + 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.running_id = "modern".into(); + app.agent_chat.state = Some(State::Starting); + let ctx = crate::fonts::test_context(); + let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + poll(app, &ctx); + if done(app) { + return; + } + let left = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!left.is_zero(), "timed out; status={}", status(app)); + wake_rx.recv_timeout(left).expect("backend wake"); + } + }; + until(&mut app, &|a| a.agent_chat.state() == State::Ready); + assert_eq!(app.agent_chat.model, "fast"); + for (value, expected, saved) in [("smart", "smart", "smart"), ("fast", "smart", "smart"), ("smart", "smart", "smart"), ("fast", "fast", "fast")] { + set_model(&mut app, value); + assert!(app.agent_chat.config_pending); + until(&mut app, &|a| !a.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, expected); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some(saved)); + } + disconnect(&mut app); + let requests: Vec = std::fs::read_to_string(&log).unwrap().lines().filter_map(|l| serde_json::from_str(l).ok()).collect(); + let configs: Vec<_> = requests.iter().filter(|v| v["method"] == "session/set_config_option").collect(); + assert_eq!(configs.len(), 4); + assert_eq!( + configs.iter().map(|v| v["params"].clone()).collect::>(), + ["smart", "fast", "smart", "fast"].map(|value| json!({"sessionId":"s1","configId":"vendor.model-key","value":value})) + ); + assert!(!requests.iter().any(|v| v["method"] == "session/set_model")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn modern_controls_route_by_category_and_wait_for_acknowledgement() { + let (mut app, script, _) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + assert!(app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "fast"); + assert!(!app.preferences.agent_models.contains_key("modern")); + assert_eq!(script.calls(), ["set_config_option: some.provider.model=smart"]); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + assert!(app.agent_chat.config_pending, "a notification does not acknowledge the request"); + app.agent_chat.on_event(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "smart"); + app.preferences.agent_models.insert("modern".into(), app.agent_chat.model.clone()); + set_mode(&mut app, "ask"); + assert_eq!(script.calls().last().map(String::as_str), Some("set_config_option: custom-mode-key=ask")); +} + +#[test] +fn modern_rejection_and_malformed_success_unlock_without_changing_confirmed_value() { + let (mut app, script, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + app.agent_chat.on_event(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Err("rejected".into()) }); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "fast"); + set_model(&mut app, "smart"); + app.agent_chat.on_event(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Err("malformed snapshot".into()) }); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "fast"); + assert_eq!(script.calls().len(), 2); +} + +#[test] +fn modern_saved_model_restore_waits_for_ack_and_manual_confirmation_persists() { + let (mut app, script, ctx) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.preferences.agent_models.insert("modern".into(), "smart".into()); + script.report(Event::ConfigOptions(modern_options("fast"))); + script.report(Event::Ready { agent: "modern".into(), images: false }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "fast"); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); + assert_eq!(script.calls(), ["set_config_option: some.provider.model=smart"]); + script.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); +} + +#[test] +fn modern_model_restore_then_mode_restore_are_acknowledged_before_queued_prompt() { + let (mut app, script, ctx) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.preferences.agent_models.insert("modern".into(), "smart".into()); + app.agent_chat.restore_mode = Some("plan".into()); + app.agent_chat.queued = Some(("describe the model".into(), false, None, Vec::new())); + script.report(Event::ConfigOptions(modern_options("fast"))); + script.report(Event::Ready { agent: "modern".into(), images: false }); + poll(&mut app, &ctx); + assert_eq!(script.calls(), ["set_config_option: some.provider.model=smart"]); + assert_eq!(app.agent_chat.model, "fast"); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); + script.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert_eq!(script.calls().get(1).map(String::as_str), Some("set_config_option: custom-mode-key=plan")); + assert!(app.agent_chat.queued.is_some(), "prompt waits for mode confirmation"); + let mut confirmed = modern_options("smart"); + confirmed[1].current_value = "plan".into(); + script.report(Event::ConfigOptionFinished { id: "custom-mode-key".into(), result: Ok(confirmed) }); + poll(&mut app, &ctx); + assert!(script.calls().iter().any(|s| s == "prompt: describe the model")); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); +} + +#[test] +fn invalidated_modern_sessions_do_not_expose_stale_legacy_choices() { + for event in [Event::AuthRequired { methods: vec![] }, Event::Exited { tail: vec![] }] { + let (mut app, _, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("smart"))); + app.agent_chat.config_pending = true; + app.agent_chat.on_event(event); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + assert!(app.agent_chat.models.is_empty() && app.agent_chat.modes.is_empty()); + assert!(app.agent_chat.model.is_empty() && app.agent_chat.mode.is_empty()); + } + let (mut app, _, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("smart"))); + new_chat(&mut app); + assert_eq!(app.agent_chat.restore_mode.as_deref(), Some("ask")); + assert!(app.agent_chat.config_options.is_none() && app.agent_chat.models.is_empty()); +} + /// An agent that reports no models gets no picker; another agent's choice is not applied to it. #[test] fn an_agent_without_models_shows_no_picker() { @@ -2657,6 +3021,27 @@ fn no_tool_turn_warning_leaves_answers_and_incomplete_turns_quiet() { } } +/// A turn that ends with nothing shown because the reply hit its length limit, or the model +/// declined, says so instead of blaming tool support (#9495). +#[test] +fn a_cut_off_or_declined_reply_says_so_instead_of_blaming_tools() { + for (reason, said) in [("max_tokens", "ui.agent_chat_reply_cut_off"), ("refusal", "ui.agent_chat_reply_declined")] { + let (mut app, _, _) = app_with_script(); + app.agent_chat.tools_port = Some(12345); + start_turn(&mut app, "Make a box".into(), false, None, vec![]); + app.agent_chat.on_event(Event::TurnEnded { reason: reason.into() }); + end_turn(&mut app); + assert!(status(&app)["turnWarning"].is_null(), "{reason}"); + let notices: Vec<&str> = app + .agent_chat + .items + .iter() + .filter_map(|i| if let Item::Notice { text, error: false } = i { Some(text.as_str()) } else { None }) + .collect(); + assert_eq!(notices, [tr(said)], "{reason}"); + } +} + // ---- Tool calls in words (#1457) ---- fn in_mm() -> summary::Units<'static> { @@ -2844,3 +3229,79 @@ fn a_screenshot_card_hides_the_path_until_details() { h.frames(3); assert!(h.widget("Result").is_some() && h.widget(path).is_some(), "Details holds the full path"); } + +/// The composer's input box fills its parent composer area on all four sides, in every layout: +/// the only inset left is the text's own padding inside the box (no second margin around it). +#[test] +fn the_composer_box_fills_its_parent_in_every_layout() { + use crate::scenario::rect_of; + for (layout, popped) in [("floating", false), ("docked", false), ("floating", true), ("docked", true)] { + let mut h = narrow_chat(); + h.call("ui.resize", json!({"width": 1600, "height": 1000})); + h.call("ui.set", json!({"layout": layout})); + h.app.preferences.agent_chat_popped = popped; + h.app.agent_chat.state = Some(State::Ready); + h.frames(4); + let footer = rect_of("agent_chat:composer_footer").unwrap_or_else(|| panic!("{layout} popped={popped}: the composer area")); + let boxed = rect_of("agent_chat:composer_box").expect("the input box"); + let (l, r, t, b) = + (boxed.left() - footer.left(), footer.right() - boxed.right(), boxed.top() - footer.top(), footer.bottom() - boxed.bottom()); + for (edge, gap) in [("left", l), ("right", r), ("top", t), ("bottom", b)] { + assert!(gap.abs() <= 1.0, "{layout} popped={popped}: box {edge} edge is {gap} from its parent ({boxed:?} in {footer:?})"); + } + let input = rect_of("agent_chat:input").expect("the text"); + assert!(input.left() > boxed.left() + 2.0, "{layout} popped={popped}: the text keeps its own padding"); + let send = rect_of("agent_chat:send").expect("Send"); + assert!(boxed.contains_rect(send), "{layout} popped={popped}: Send stays inside the box"); + } +} + +/// The composer hint (idle and busy, in every language) stays on one row, even at the narrowest +/// panel, and is cut with an ellipsis instead of wrapping. +#[test] +fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() { + let mut h = narrow_chat(); + h.app.agent_chat.state = Some(State::Ready); + h.frames(4); + let width = crate::scenario::rect_of("agent_chat:input").expect("the text box").width(); + let ctx = crate::fonts::test_context(); + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/locales"); + let mut checked = 0; + for entry in std::fs::read_dir(dir).unwrap().filter_map(Result::ok) { + let path = entry.path(); + if path.extension().is_none_or(|x| x != "json") { + continue; + } + let language = path.file_stem().unwrap().to_string_lossy().into_owned(); + let _scope = crate::i18n::scope(&language); + for key in ["ui.agent_chat_placeholder", "ui.agent_chat_placeholder_busy"] { + let hint = crate::i18n::tr(key).to_owned(); + let mut rows = 0; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + rows = crate::ui_kit::assistant_hint_galley(ui, &hint, width - 2.0 * crate::theme::Tokens::get().form_inset).rows.len(); + }); + output.textures_delta.clear(); + assert_eq!(rows, 1, "{language} {key} at {width} wide: {hint}"); + checked += 1; + } + } + assert!(checked >= 20, "every language was checked"); +} + +/// The room tool windows get beside the docked Assistant, in a view that came out inside out +/// (the window of #9492): the Assistant's side gives way instead of panicking in `f32::clamp`. +#[test] +fn tool_room_beside_the_docked_assistant_survives_an_inside_out_view() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.layout = crate::shell::LayoutMode::Floating; + app.preferences.ai_features = true; + app.agent_chat.open = true; + app.preferences.agent_chat_popped = false; + app.agent_chat.panel_rect = Some(egui::Rect::from_min_max(egui::pos2(100.0, 40.0), egui::pos2(150.0, 600.0))); + let inside_out = egui::Rect::from_min_max(egui::pos2(264.1875, 40.0), egui::pos2(159.0, 600.0)); + for side in ["right", "left"] { + app.preferences.agent_chat_side = side.into(); + let free = tool_space(&app, inside_out); + assert!(free.min.x.is_finite() && free.max.x.is_finite(), "{side}: {free:?}"); + } +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 3fa35fe533..0476a0d3fb 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -24,6 +24,7 @@ mod model; mod features; mod point_clouds; pub use point_clouds::CloudPanel; +pub(crate) use point_clouds::point_cloud_tracks; pub(crate) fn reveal_feature(app: &mut SolveApp, id: u64, body: &str) { features::reveal(app, id, body); @@ -57,8 +58,16 @@ pub(crate) fn reveal_body(app: &mut SolveApp, body: &str) { let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(&path) }; app.tree.collapsed.remove(&scope); app.tree.collapsed.remove(&format!("{scope}/bodies")); - for g in app.session.doc.browser_groups.iter().filter(|g| g.component == component && g.folder == "bodies" && g.items.contains(&source)) { - app.tree.collapsed.remove(&format!("{scope}/g{}", g.id)); + open_group_chain(app, &scope, component, "bodies", &source); +} + +/// Open the group holding an item, and the groups holding that one. +fn open_group_chain(app: &mut SolveApp, scope: &str, component: u64, folder: &str, key: &str) { + let mut at = app.session.doc.group_of_item(component, folder, key).map(|g| g.id); + for _ in 0..=solidcraft_engine::doc::MAX_GROUP_NESTING { + let Some(id) = at else { return }; + app.tree.collapsed.remove(&if scope.is_empty() { format!("g{id}") } else { format!("{scope}/g{id}") }); + at = app.session.doc.browser_group(id).and_then(|g| g.parent); } } @@ -76,7 +85,7 @@ use std::sync::{Arc, Weak}; use egui::{Color32, FontId, Pos2, Rect, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; use solidcraft_engine::Sel; -use solidcraft_engine::doc::{Document, FeatureKind}; +use solidcraft_engine::doc::{BrowserEntry, Document, FeatureKind}; use crate::context_menu::{Item, Target}; use crate::theme::Tokens; @@ -316,7 +325,7 @@ pub fn group_bodies(app: &SolveApp, id: u64) -> Vec { let st = app.session.world_state(); find_group(app, id) .filter(|g| g.folder == "bodies") - .map(|g| g.items.iter().filter(|b| st.body(b).is_some()).cloned().collect()) + .map(|_| app.session.doc.group_item_keys(id).into_iter().filter(|b| st.body(b).is_some()).collect()) .unwrap_or_default() } @@ -347,6 +356,10 @@ pub fn group_items(app: &SolveApp, id: u64, scope: &str) -> Vec { if g.folder == "bodies" || g.folder == "sketches" { v.push(paste_item(app, g.component, scope, &g.folder, Some(id))); } + // A group inside this one, unless it is already as deep as groups go. + if app.session.doc.group_level(id) < solidcraft_engine::doc::MAX_GROUP_NESTING { + v.push(Item::action("ui.newGroup", "New Group", "folder").with(json!({ "component": g.component, "folder": g.folder, "parent": id }))); + } v.extend([ Item::action("ui.rename", "Rename", "rename").with(json!({ "group": id })), Item::action("ui.ungroup", "Ungroup", "folder").with(json!({ "group": id })), @@ -373,10 +386,7 @@ pub fn folder_items(app: &SolveApp, component: u64, folder: &str, scope: &str) - if folder == "sketches" { v.push(paste_item(app, component, scope, folder, None)); } - v.push( - Item::action("ui.newGroup", if folder == "sketches" { "New Sketch Group" } else { "New Group" }, "folder") - .with(json!({ "component": component, "folder": folder })), - ); + v.push(Item::action("ui.newGroup", "New Group", "folder").with(json!({ "component": component, "folder": folder }))); if sel.len() > 1 { v.push(Item::action("ui.groupSelected", "Group Selected", "folder").with(json!({ "component": component, "folder": folder, "items": sel }))); } @@ -470,9 +480,13 @@ pub fn group_action(app: &mut SolveApp, action: &str, p: &Value) { let folder = p.get("folder").and_then(Value::as_str).unwrap_or("bodies"); let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0); let items = p.get("items").cloned().unwrap_or(json!([])); - if let Ok(r) = app.run("browser.group", json!({ "component": comp, "folder": folder, "items": items })) + let parent = p.get("parent").and_then(Value::as_u64); + if let Ok(r) = app.run("browser.group", json!({ "component": comp, "folder": folder, "items": items, "parent": parent })) && let Some(g) = r.get("group").and_then(Value::as_u64) { + if let Some(parent) = parent { + app.tree.collapsed.remove(&format!("c{comp}/g{parent}")); + } app.tree.collapsed.remove(&format!("c{comp}/{folder}")); app.tree.collapsed.remove(&format!("c{comp}/g{g}")); } @@ -511,9 +525,18 @@ fn compact_tree(ui: &egui::Ui) -> bool { ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_compact_tree"))).unwrap_or(false) } +/// Where tree rows put their eye this frame (Preferences › Interface, #8718). +pub(crate) fn publish_row_prefs(app: &SolveApp, ctx: &egui::Context) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_tree_eye_left"), app.preferences.browser_eye_left)); +} + +fn eye_left(ui: &egui::Ui) -> bool { + ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_tree_eye_left"))).unwrap_or(false) +} + pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row<'_>) -> RowResp { let compact = compact_tree(ui); - let out = crate::ui_kit::tree_row(ui, id, &Row { compact, ..*row }); + let out = crate::ui_kit::tree_row(ui, id, &Row { compact, eye_left: eye_left(ui), ..*row }); if let Some(r) = out.rect.filter(|r| ui.is_rect_visible(*r)) { let x = r.left() + if compact { Tokens::get().space_8 } else { 2.0 } + row.depth as f32 * INDENT; let fold = row.fold.map(|_| Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom()))); @@ -1223,6 +1246,7 @@ fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec< .doc .features .iter() + .take(app.session.doc.marker.unwrap_or(app.session.doc.features.len())) .enumerate() .filter_map(|(i, f)| { let kind = if sketches { @@ -1458,7 +1482,7 @@ fn component_rows_inner( } // Bodies and sketches dropped on a component move into it. if let Some(resp) = &r.resp - && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != id && d.folder != "construction", DropMark::Box) + && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != id && d.folder != "construction" && d.groups.is_empty(), DropMark::Box) { acts.drop = Some(DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() }); } @@ -1583,10 +1607,13 @@ fn occurrence_rows_inner( ); if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) { OCCURRENCES.with(|d| d.borrow_mut().push((path.to_vec(), placement.name.clone(), rect, open))); - crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y)); - let x = rect.left() + 2.0 + depth as f32 * INDENT; - for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] { - crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), pos2(x + offset, rect.center().y)); + // Near the row's end, clear of an eye there. + let end = r.eye_at.filter(|e| e.x > rect.center().x).map_or(rect.right() - 12.0, |e| e.x - 24.0); + crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(end, rect.center().y)); + for (control, at) in [("fold", r.fold_at), ("eye", r.eye_at), ("activate", r.radio_at)] { + if let Some(at) = at { + crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), at); + } } } if let Some(resp) = &r.resp { @@ -1641,14 +1668,33 @@ fn occurrence_rows_inner( acts.menu = Some((p, Target::Occurrence { path: path.to_vec() })); } if let Some(resp) = &r.resp - && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction", DropMark::Box) + && let Some(d) = + drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction" && d.groups.is_empty(), DropMark::Box) { acts.drop = Some(DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() }); } + // The top of a sibling's row puts the dragged placement before it; the rest of the row, and + // any other row, moves the placement into this row's component. + let top = r.resp.as_ref().is_some_and(|resp| pointer_in_top(ui, resp.rect)); + if let Some(resp) = &r.resp + && top + && let Some(d) = occurrence_reorder_target(ui, resp, |d| occurrence_sibling(app, d, placement.occurrence).is_some()) + && let Some(parent) = occurrence_sibling(app, &d, placement.occurrence) + { + let dragged = d.path.last().copied().unwrap_or(0); + let mut order: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == parent && o.id != dragged).map(|o| o.id).collect(); + app.session.doc.sort_placements(parent, &mut order); + let at = order.iter().position(|o| *o == placement.occurrence).unwrap_or(order.len()); + order.insert(at, dragged); + let order: Vec = order.iter().map(u64::to_string).collect(); + let _ = app.run("browser.order", json!({ "folder": "components", "component": parent, "order": order })); + } // A placement row dropped on this one moves into this row's component. if let Some(resp) = &r.resp - && let Some(d) = occurrence_drop_target(ui, resp, |d| occurrence_can_land(app, d, placement.component)) + && let Some(d) = occurrence_drop_target(ui, resp, |d| { + occurrence_can_land(app, d, placement.component) && !(top && occurrence_sibling(app, d, placement.occurrence).is_some()) + }) { let occurrence = d.path.last().copied().unwrap_or(0); acts.drop = Some(DropAction::Reparent { occurrence, path: d.path.clone(), parent: placement.component, parent_path: Some(path.to_vec()) }); @@ -2098,6 +2144,8 @@ pub struct CanvasPanel { pub a: [f64; 2], pub b: [f64; 2], pub distance: f64, + /// Next viewport click sets Point A (`0`) or Point B (`1`) (#9108). + pub next: u8, } impl CanvasPanel { @@ -2111,7 +2159,7 @@ impl CanvasPanel { .find(|c| c.id == canvas) .map(|c| ([c.center.x - c.width / 2.0, c.center.y], [c.center.x + c.width / 2.0, c.center.y], c.width)) .unwrap_or(([0.0, 0.0], [100.0, 0.0], 100.0)); - CanvasPanel { canvas, calibrate, depth: app.session.undo.len(), a, b, distance: d } + CanvasPanel { canvas, calibrate, depth: app.session.undo.len(), a, b, distance: d, next: 0 } } } @@ -2123,6 +2171,16 @@ fn dialog_takes_body(app: &SolveApp) -> bool { app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::BODIES != 0) } +/// A plain click on a sketch (a Browser row or a History item) while Extrude, Revolve or Emboss +/// is open gives the dialog every profile of that sketch (#6106). False when it is not one: the +/// click then selects as usual. +pub(crate) fn pick_sketch_into_dialog(app: &mut SolveApp, id: u64) -> bool { + let Some(mut d) = app.dialog.take() else { return false }; + let taken = d.pick_sketch(&app.session, id); + app.dialog = Some(d); + taken +} + /// Browser picks go only into compatible active dialog inputs. Outside a dialog, planes and /// axes become the selection; body rows use the ordinary Ctrl/Shift-aware selection path. fn pick_from_browser(app: &mut SolveApp, x: Sel) { @@ -2203,7 +2261,7 @@ impl Entry { label: b.name.clone(), icon, tip: Some(tip), - visible: !app.session.visibility.hidden_bodies.contains(&world), + visible: app.body_visible(&world), selected: app.session.selection.iter().any(|s| matches!(s, Sel::Body { name } if name == &world)), color: None, sketch_state: None, @@ -2293,6 +2351,15 @@ struct Drag { comp: u64, folder: &'static str, keys: Vec, + /// Groups dragged by their rows (alone: a drag carries items or groups). + groups: Vec, +} + +impl Drag { + /// What is dragged, as the browser commands name it. + fn entries(&self) -> Vec { + self.keys.iter().map(|k| BrowserEntry::item(k)).chain(self.groups.iter().map(|g| BrowserEntry::Group(*g))).collect() + } } /// A placement row dragged in the tree: the exact path it sits at, and the component it places. @@ -2329,34 +2396,56 @@ fn construction_sel(f: &solidcraft_engine::doc::Feature) -> Sel { } } -/// A folder's groups (with their items) and the items in no group, in browser order. -fn arrange<'a>( - doc: &'a solidcraft_engine::doc::Document, - comp: u64, - folder: &str, - all: Vec, -) -> (Vec<(&'a solidcraft_engine::doc::BrowserGroup, Vec)>, Vec) { - let mut rest: Vec> = all.into_iter().map(Some).collect(); - let mut take = |key: &str| rest.iter_mut().find(|e| e.as_ref().is_some_and(|e| e.key == key)).and_then(Option::take); - let grouped: Vec<_> = doc - .browser_groups - .iter() - .filter(|g| g.component == comp && g.folder == folder) - .map(|g| { - let items = g.items.iter().filter_map(|k| take(k)).collect(); - (g, items) - }) - .collect(); - let mut loose: Vec = rest.into_iter().flatten().collect(); - if let Some(order) = doc.browser_order.get(&format!("{comp}/{folder}")) { - let mut positions = std::collections::HashMap::with_capacity(order.len()); - for (i, key) in order.iter().enumerate() { - positions.entry(key.as_str()).or_insert(i); - } - let pos = |k: &str| positions.get(k).copied().unwrap_or(usize::MAX); - loose.sort_by_key(|e| pos(&e.key)); +/// A folder's rows in browser order (see `Document::ordered_entries`), then the items in no list +/// in their natural order. A group's rows are ordered the same way by its own list. `prefix` is +/// the scope the groups' tree keys start with. +fn arrange(doc: &solidcraft_engine::doc::Document, comp: u64, folder: &str, prefix: &str, all: Vec) -> Vec { + use cache::Row; + use solidcraft_engine::doc::BrowserEntry; + let mut index: std::collections::HashMap = std::collections::HashMap::with_capacity(all.len()); + for (i, e) in all.iter().enumerate() { + index.entry(e.key.clone()).or_insert(i); } - (grouped, loose) + let mut pool: Vec> = all.into_iter().map(Some).collect(); + let mut take = |key: &str| index.get(key).and_then(|i| pool[*i].take()).map(|e| Row::Item(Box::new(e))); + fn build( + doc: &solidcraft_engine::doc::Document, + comp: u64, + folder: &str, + prefix: &str, + id: u64, + left: usize, + take: &mut dyn FnMut(&str) -> Option, + ) -> Option { + let g = doc.browser_group(id)?; + let mut rows = Vec::new(); + for e in doc.ordered_entries(comp, folder, Some(id)) { + match e { + BrowserEntry::Item(k) => rows.extend(take(&k)), + BrowserEntry::Group(c) if left > 0 => rows.extend(build(doc, comp, folder, prefix, c, left - 1, take).map(Row::Group)), + BrowserEntry::Group(_) => {} + } + } + let count = rows.iter().map(Row::count).sum(); + Some(cache::Group { id, key: format!("{prefix}/g{id}"), label: format!("{} ({count})", g.name), count, rows }) + } + let order = doc.ordered_entries(comp, folder, None); + // Groups first, so an item in a group is never also listed loose. + let mut groups: std::collections::HashMap = std::collections::HashMap::new(); + for e in &order { + if let BrowserEntry::Group(id) = e { + groups.extend(build(doc, comp, folder, prefix, *id, solidcraft_engine::doc::MAX_GROUP_NESTING, &mut take).map(|g| (*id, g))); + } + } + let mut rows = Vec::new(); + for e in order { + match e { + BrowserEntry::Group(id) => rows.extend(groups.remove(&id).map(Row::Group)), + BrowserEntry::Item(k) => rows.extend(take(&k)), + } + } + rows.extend(pool.into_iter().flatten().map(|e| Row::Item(Box::new(e)))); + rows } /// Show or hide items of a folder, through `browser.visibility` so each is an undo step (#2219). @@ -2437,14 +2526,12 @@ fn folder_rows_at( { app.tree.collapsed.remove(key); app.tree.collapsed.remove(&format!("c{comp}")); - for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&e.key)) { - app.tree.collapsed.remove(&format!("g{}", g.id)); - } + open_group_chain(app, "", comp, "bodies", &e.key); } // A folded folder draws no row: its groups and items move up into its place, and their own // eyes, hover and drops do its jobs. It is always open, as nothing could open it again. let render = crate::render_workspace::active(app); - let folded = !render && folder == "bodies" && bodies_folded(app, comp, prefix, data.entries().count()); + let folded = !render && folder == "bodies" && bodies_folded(app, comp, prefix, data.count); // With the history in the browser the sketches are there too: their folder starts closed. let default_open = !(folder == "sketches" && app.preferences.history_in_browser); let open = folded || is_open(app, key, default_open); @@ -2468,7 +2555,7 @@ fn folder_rows_at( icon: "folder", label: folder_label(folder), dim: folder_faded, - mark: acts.lit.branch(app, data.entries()), + mark: if acts.lit.any() { acts.lit.branch(app, data.entries()) } else { Mark::None }, ..Default::default() }, ) @@ -2497,10 +2584,21 @@ fn folder_rows_at( // from another component's folder move into this component, as a drop on its row does // (#3270: the Bodies folder looked like the place to drop a body, and took nothing). if let Some(resp) = &r.resp - && let Some(d) = drop_target(ui, resp, |d: &Drag| d.folder == fstatic && (d.comp == comp || d.folder != "construction"), DropMark::Box) + && let Some(d) = drop_target( + ui, + resp, + |d: &Drag| d.folder == fstatic && (d.comp == comp || (d.folder != "construction" && d.groups.is_empty())), + DropMark::Box, + ) { acts.drop = Some(if d.comp == comp { - DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: None, before: None } + DropAction::Order { + comp, + folder: fstatic, + keys: d.keys.clone(), + groups: d.groups.clone(), + order: order_before(&data.top_order, &d.entries(), None), + } } else { DropAction::Component { to: comp, folder: d.folder, keys: d.keys.clone() } }); @@ -2509,28 +2607,56 @@ fn folder_rows_at( return; } } - let loose_keys = &data.loose_keys; - if render { - for e in &data.loose { - entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, loose_keys, folder_faded, acts); - } - } - for group in &data.groups { - let (id, items, gkey) = (group.id, &group.items, &group.key); + folder_level(app, ui, comp, path, fstatic, prefix, &data.rows, None, inner, &data.top_order, folder_faded, acts); +} + +/// The rows of a folder's top level, or of one group (`holder`), at `depth`; groups draw their +/// own rows the same way one level deeper. `top` is the folder's top-level order as drawn. +#[allow(clippy::too_many_arguments)] +fn folder_level( + app: &mut SolveApp, + ui: &mut egui::Ui, + comp: u64, + path: Option<&[u64]>, + fstatic: &'static str, + prefix: &str, + rows: &[cache::Row], + holder: Option, + depth: usize, + top: &[BrowserEntry], + faded: bool, + acts: &mut Actions, +) { + let render = crate::render_workspace::active(app); + // The render workspace has always listed a folder's loose items above its groups. + let items_first = render && holder.is_none(); + let rows = rows + .iter() + .filter(|r| items_first && matches!(r, cache::Row::Item(_))) + .chain(rows.iter().filter(|r| !items_first || matches!(r, cache::Row::Group(_)))); + for row in rows { + let group = match row { + cache::Row::Item(e) => { + entry_row(app, ui, comp, path, fstatic, e, depth, holder, top, faded, acts); + continue; + } + cache::Row::Group(g) => g, + }; + let (id, gkey) = (group.id, &group.key); let gopen = is_open(app, gkey, true); let gshown = !app.session.folder_hidden(gkey); - let group_faded = folder_faded || !gshown; + let group_faded = faded || !gshown; let label = if render { app.session.doc.browser_groups.iter().find(|g| g.id == id).map_or(group.label.as_str(), |g| g.name.as_str()) } else { &group.label }; - let count = if render && !gopen { crate::i18n::format("render.tree_more", &[("count", &items.len().to_string())]) } else { String::new() }; + let count = if render && !gopen { crate::i18n::format("render.tree_more", &[("count", &group.count.to_string())]) } else { String::new() }; let r = draw_row( ui, ui.id().with(gkey), &Row { - depth: inner, + depth, fold: Some(gopen), eye: (!render || gopen).then_some(gshown), icon: if render { "" } else { "folder" }, @@ -2538,14 +2664,14 @@ fn folder_rows_at( name: true, dim: group_faded || (render && !gopen), count: &count, - mark: acts.lit.branch(app, items), + mark: if acts.lit.any() { acts.lit.branch(app, group.entries()) } else { Mark::None }, ..Default::default() }, ); if r.fold { toggle(app, gkey, true); } else if r.clicked { - let names: Vec = items.iter().flat_map(|e| e.bodies.clone()).collect(); + let names: Vec = group.entries().into_iter().flat_map(|e| e.bodies.clone()).collect(); if !offer_bodies(app, &names, Take::Toggle) { pick_folder(app, gkey); toggle(app, gkey, true); @@ -2555,40 +2681,73 @@ fn folder_rows_at( toggle_folder(app, gkey); } if r.hovered { - acts.hover(items.iter().flat_map(|e| &e.bodies)); + acts.hover(group.entries().into_iter().flat_map(|e| &e.bodies)); } if let Some(p) = r.secondary { acts.menu = Some((p, Target::Group { id, scope: prefix.to_owned() })); } - if let Some(resp) = &r.resp - && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box) - { - acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(id), before: None }); - } - if gopen { - for e in items { - entry_row(app, ui, comp, path, fstatic, e, inner + 1, Some(id), loose_keys, group_faded, acts); + if let Some(resp) = &r.resp { + // Dragging a group row moves the group; a press on its arrow or eye is not a drag. + if resp.drag_started() && !r.on_control { + resp.dnd_set_drag_payload(Drag { comp, folder: fstatic, keys: Vec::new(), groups: vec![id] }); + } + // The top of the row puts the drop before the group; the rest puts it inside. + let level = app.session.doc.group_level(id); + let fits = |d: &Drag, parent_level: usize| { + let doc = &app.session.doc; + d.groups.iter().all(|g| !doc.group_within(id, *g) && parent_level + doc.group_height(*g) <= solidcraft_engine::doc::MAX_GROUP_NESTING) + }; + if let Some((d, zone)) = drop_target_zone(ui, resp, |d: &Drag, zone| { + d.comp == comp && d.folder == fstatic && fits(d, if zone == DropMark::Above { level - 1 } else { level }) + }) { + let before = BrowserEntry::Group(id); + acts.drop = Some(match (zone, holder) { + (DropMark::Box, _) => { + DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(id), before: None } + } + (DropMark::Above, Some(h)) => DropAction::Move { + comp, + folder: fstatic, + keys: d.keys.clone(), + groups: d.groups.clone(), + group: Some(h), + before: Some(before), + }, + (DropMark::Above, None) => DropAction::Order { + comp, + folder: fstatic, + order: order_before(top, &d.entries(), Some(&before)), + keys: d.keys.clone(), + groups: d.groups.clone(), + }, + }); } } - } - if !render { - for e in &data.loose { - entry_row(app, ui, comp, path, fstatic, e, inner, None, loose_keys, folder_faded, acts); + if gopen { + folder_level(app, ui, comp, path, fstatic, prefix, &group.rows, Some(id), depth + 1, top, group_faded, acts); } } } +/// `top` with the dragged entries moved to just before `before` (to the end without it). +fn order_before(top: &[BrowserEntry], dragged: &[BrowserEntry], before: Option<&BrowserEntry>) -> Vec { + let mut order: Vec = top.iter().filter(|e| !dragged.contains(e)).cloned().collect(); + let at = before.and_then(|b| order.iter().position(|e| e == b)).unwrap_or(order.len()); + for (i, e) in dragged.iter().enumerate() { + order.insert(at + i, e.clone()); + } + order +} + /// The body's name edited in its row (started by a double-click): Enter or leaving the box keeps /// the name, Escape cancels. -fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, e: &Entry, depth: usize) { +fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, label_left: f32, e: &Entry) { let editing = app.menu.rename.as_ref().is_some_and(|r| r.inline && matches!(&r.what, crate::context_menu::RenameWhat::Body(b) if *b == e.key)); if !editing { return; } let Some(mut rename) = app.menu.rename.take() else { return }; - // Where the label starts: the fold arrow, eye and icon come first. - let left = row.left() + 2.0 + depth as f32 * INDENT + 14.0 + 18.0 + 21.0; - let edit_rect = Rect::from_min_max(pos2(left, row.top() + 1.0), pos2(row.right() - 3.0, row.bottom() - 1.0)); + let edit_rect = Rect::from_min_max(pos2(label_left, row.top() + 1.0), pos2(row.right() - 3.0, row.bottom() - 1.0)); ui.painter().rect_filled(edit_rect, 3.0, Tokens::get().field); let id = ui.id().with(("browser_rename", &e.row_key)); let response = crate::ui_kit::inline_name(ui, edit_rect, id, &mut rename.text); @@ -2625,7 +2784,7 @@ fn entry_row( e: &Entry, depth: usize, group: Option, - loose: &[String], + top: &[BrowserEntry], // A placement or folder above is hidden: the row fades, its eye stays its own. parent_hidden: bool, acts: &mut Actions, @@ -2712,6 +2871,12 @@ fn entry_row( } pick_from_browser(app, s); } + } else if folder == "sketches" + && !ui.input(|i| i.modifiers.any()) + && let Some(Sel::Feature { id }) = e.sel + && pick_sketch_into_dialog(app, id) + { + // Extrude, Revolve or Emboss took all the sketch's profiles (#6106). } else { click_select(app, ui, e.row_key.clone(), e.sel.clone()); } @@ -2728,9 +2893,9 @@ fn entry_row( } } if folder == "bodies" - && let Some(rect) = r.rect + && let (Some(rect), Some(label_left)) = (r.rect, r.label_left) { - inline_rename(app, ui, rect, e, depth); + inline_rename(app, ui, rect, label_left, e); } if r.hovered && !e.bodies.is_empty() { acts.hover(e.bodies.iter()); @@ -2755,19 +2920,26 @@ fn entry_row( // Drag: the selected items of this folder when this one is selected, else just this one. if resp.drag_started() && !r.on_control { let keys: Vec = if selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] }; - resp.dnd_set_drag_payload(Drag { comp, folder, keys }); + resp.dnd_set_drag_payload(Drag { comp, folder, keys, groups: Vec::new() }); } - if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key), DropMark::Above) { + let fits = |d: &Drag| { + let doc = &app.session.doc; + let level = group.map_or(0, |g| doc.group_level(g)); + d.groups + .iter() + .all(|g| group.is_none_or(|h| !doc.group_within(h, *g)) && level + doc.group_height(*g) <= solidcraft_engine::doc::MAX_GROUP_NESTING) + }; + if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key) && fits(d), DropMark::Above) { + let before = BrowserEntry::item(&e.key); acts.drop = Some(match group { - Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), group: Some(g), before: Some(e.key.clone()) }, - None => { - let mut order: Vec = loose.iter().filter(|k| !d.keys.contains(k)).cloned().collect(); - let at = order.iter().position(|k| *k == e.key).unwrap_or(order.len()); - for (i, k) in d.keys.iter().enumerate() { - order.insert(at + i, k.clone()); - } - DropAction::Order { comp, folder, keys: d.keys.clone(), order } - } + Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(g), before: Some(before) }, + None => DropAction::Order { + comp, + folder, + order: order_before(top, &d.entries(), Some(&before)), + keys: d.keys.clone(), + groups: d.groups.clone(), + }, }); } } @@ -2805,6 +2977,26 @@ fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> b resp.dnd_release_payload::().filter(|d| accept(d)) } +/// Like [`drop_target`] for a row that takes a drop above it (its top part) or inside it. +fn drop_target_zone(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag, DropMark) -> bool) -> Option<(std::sync::Arc, DropMark)> { + let t = Tokens::get(); + let r = resp.rect; + let at = ui.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos())); + let zone = if at.is_some_and(|p| p.y < r.top() + r.height() * 0.3) { DropMark::Above } else { DropMark::Box }; + if resp.dnd_hover_payload::().is_some_and(|d| accept(&d, zone)) { + match zone { + DropMark::Above => ui.painter().hline(r.x_range(), r.top(), Stroke::new(2.0, t.accent)), + DropMark::Box => { + ui.painter().rect_stroke(r.shrink(0.5), t.radius_sm.saturating_sub(1), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside) + } + }; + } + if !egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + return None; + } + resp.dnd_release_payload::().filter(|d| accept(d, zone)).map(|d| (d, zone)) +} + /// Whether a placement dragged from `d` may land in component `parent`: not where it already is, /// and not in itself or a component it holds. The command refuses those too; this keeps the row /// from showing a drop mark it would refuse. @@ -2819,6 +3011,35 @@ fn occurrence_can_land(app: &SolveApp, d: &OccurrenceDrag, parent: u64) -> bool o.parent != parent && !app.session.doc.inside_component(parent, d.component) } +/// Whether the pointer is over the top part of a row (where a drop goes before it). +fn pointer_in_top(ui: &egui::Ui, row: Rect) -> bool { + ui.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos())).is_some_and(|p| p.y < row.top() + row.height() * 0.3) +} + +/// The component a placement shares with the placement under it, when both sit in the same one +/// (and are two placements): the parent whose sub-components they are ordered among. +fn occurrence_sibling(app: &SolveApp, d: &OccurrenceDrag, target: u64) -> Option { + let dragged = d.path.last().copied().filter(|o| *o != target)?; + let occurrences = &app.session.doc.occurrences; + let parent = occurrences.iter().find(|o| o.id == dragged)?.parent; + occurrences.iter().any(|o| o.id == target && o.parent == parent).then_some(parent) +} + +/// While a matching placement drag hovers the row, mark a drop before it; on release, the payload. +fn occurrence_reorder_target( + ui: &egui::Ui, + resp: &egui::Response, + accept: impl Fn(&OccurrenceDrag) -> bool, +) -> Option> { + if resp.dnd_hover_payload::().is_some_and(|d| accept(&d)) { + ui.painter().hline(resp.rect.x_range(), resp.rect.top(), Stroke::new(2.0, Tokens::get().accent)); + } + if !egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + return None; + } + resp.dnd_release_payload::().filter(|d| accept(d)) +} + /// While a matching placement drag hovers the row, mark where it would land; on release, the payload. fn occurrence_drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&OccurrenceDrag) -> bool) -> Option> { let t = Tokens::get(); @@ -2837,22 +3058,37 @@ fn occurrence_drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn( /// A drop, applied after the tree is drawn. enum DropAction { /// Into a group (before an item of it), or out of groups. - Move { comp: u64, folder: &'static str, keys: Vec, group: Option, before: Option }, - /// Out of groups, in this order of the folder's loose items. - Order { comp: u64, folder: &'static str, keys: Vec, order: Vec }, + Move { comp: u64, folder: &'static str, keys: Vec, groups: Vec, group: Option, before: Option }, + /// Out of groups, in this order of the folder's top level. + Order { comp: u64, folder: &'static str, keys: Vec, groups: Vec, order: Vec }, /// Into another component (bodies and sketches). Component { to: u64, folder: &'static str, keys: Vec }, /// A placement moved into another component (or the design, `parent` 0). Reparent { occurrence: u64, path: Vec, parent: u64, parent_path: Option> }, } +fn entry_json(e: &BrowserEntry) -> Value { + match e { + BrowserEntry::Group(id) => json!(id), + BrowserEntry::Item(k) => json!(k), + } +} + +/// Items (strings) and groups (numbers), as the browser commands take them. +fn entries_json(keys: &[String], groups: &[u64]) -> Vec { + keys.iter().map(|k| json!(k)).chain(groups.iter().map(|g| json!(g))).collect() +} + fn apply_drop(app: &mut SolveApp, d: DropAction) { match d { - DropAction::Move { comp, folder, keys, group, before } => { - let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": keys, "group": group, "before": before })); + DropAction::Move { comp, folder, keys, groups, group, before } => { + let items = entries_json(&keys, &groups); + let before = before.map(|b| entry_json(&b)); + let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": items, "group": group, "before": before })); } - DropAction::Order { comp, folder, keys, order } => { - let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": keys })); + DropAction::Order { comp, folder, keys, groups, order } => { + let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": entries_json(&keys, &groups) })); + let order: Vec = order.iter().map(entry_json).collect(); let _ = app.run("browser.order", json!({ "component": comp, "folder": folder, "order": order })); } DropAction::Component { to, folder, keys } => { diff --git a/crates/ui-egui/src/browser/features.rs b/crates/ui-egui/src/browser/features.rs index 37c4f46903..424fef29d3 100644 --- a/crates/ui-egui/src/browser/features.rs +++ b/crates/ui-egui/src/browser/features.rs @@ -53,17 +53,25 @@ pub(crate) fn reveal_in_history(app: &mut SolveApp, id: u64) { app.tree.reveal_feature = Some(Reveal { id, path: Vec::new() }); } +/// Feature folders hide later rows only while editing; history always lists every feature. +fn shown_features(app: &SolveApp, compact: bool) -> usize { + let n = app.session.doc.features.len(); + if !compact && app.dialog.as_ref().is_some_and(|d| d.editing.is_some()) { app.session.doc.marker.unwrap_or(n) } else { n } +} + pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path: &[u64], depth: usize, acts: &mut Actions) { // Compact history: every feature of the component, sketches too, in history order with the // marker, in place of the timeline. let compact = app.preferences.history_in_browser; + let shown = shown_features(app, compact); let has_features = match app.tree.model.as_ref().filter(|m| m.matches(&app.session) && !compact) { - Some(model) => model.features.get(&component).is_some_and(|features| !features.is_empty()), + Some(model) => model.features.get(&component).is_some_and(|features| features.iter().any(|&index| index < shown)), None => app .session .doc .features .iter() + .take(shown) .any(|feature| feature.component == component && (compact || !matches!(feature.kind, FeatureKind::Sketch { .. }))), }; if !has_features { @@ -102,6 +110,7 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path: .doc .features .iter() + .take(shown) .enumerate() .filter_map(|(i, f)| (f.component == component && (compact || !matches!(f.kind, FeatureKind::Sketch { .. }))).then_some(i)) .collect(), @@ -116,8 +125,8 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path: if id.is_some_and(|id| lit.contains(&id) || from_view.contains(&Some(id))) { Mark::Here } else { Mark::None } }; if !compact { - for index in indices { - let _ = feature_row(app, ui, acts, &here, index, depth + 1, None, mark(app, index)); + for index in indices.into_iter().take_while(|&index| index < shown) { + let _ = feature_row(app, ui, acts, &here, index, depth + 1, None, mark(app, index), None); } return; } @@ -129,32 +138,59 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path: // Where the marker can be dropped: the top of each row (it and the sketches listed under it // first rolled back) and below the last. let mut slots: Vec<(f32, usize)> = Vec::new(); + // Where dragged rows can be dropped, as in the History at the left: these slots and one + // before the marker, each with the folder the rows join (#9565). + let drag_id = egui::Id::new(("sc_browser_history_drag", &scope)); + let mut dragged: Option> = ui.data(|d| d.get_temp(drag_id)); + let mut drops: Vec = Vec::new(); let mut marker_resp = None; for row in &plan.rows { let index = global(row.index); + let drop = DropSlot { + y: ui.cursor().min.y, + position: global(row.slot.min(row.index)), + group: crate::timeline_left::group_for(&app.session.doc.timeline_groups, row.feature), + }; if marker_resp.is_none() && index >= marker { + drops.push(drop); marker_resp = Some(marker_row(app, ui, depth + 1)); } slots.push((ui.cursor().min.y, global(row.slot))); + drops.push(DropSlot { y: ui.cursor().min.y, ..drop }); // The same fold as in the vertical History, so it stays open when the layout changes. let key = crate::timeline_left::sketches_key(row.feature); let open = is_open(app, &key, false); let listed: Vec = row.listed.iter().filter(|listed| !listed.reference).map(|listed| global(listed.index)).collect(); let fold = (!listed.is_empty()).then_some(open); - if feature_row(app, ui, acts, &here, index, depth + 1, fold, mark(app, index)) { + if feature_row(app, ui, acts, &here, index, depth + 1, fold, mark(app, index), Some(&mut dragged)) { toggle(app, &key, false); } if open { for index in listed { - let _ = feature_row(app, ui, acts, &here, index, depth + 2, None, mark(app, index)); + let _ = feature_row(app, ui, acts, &here, index, depth + 2, None, mark(app, index), Some(&mut dragged)); } } } + let end = global(indices.len()); + if marker_resp.is_none() { + drops.push(DropSlot { y: ui.cursor().min.y, position: end, group: None }); + } let resp = marker_resp.unwrap_or_else(|| marker_row(app, ui, depth + 1)); if !plan.rows.is_empty() { - slots.push((ui.cursor().min.y, global(indices.len()))); + slots.push((ui.cursor().min.y, end)); + drops.push(DropSlot { y: ui.cursor().min.y, position: end, group: None }); } drag_marker(app, ui, &resp, &slots, marker); + drag_rows(app, ui, &plan, drag_id, dragged, &drops, resp.rect.center().y); +} + +/// A place in the browser's History where dragged rows can land: its height, the timeline +/// position they move to and the folder they join (`None`: the timeline root). +#[derive(Clone, Copy)] +struct DropSlot { + y: f32, + position: usize, + group: Option, } /// Where a history row is drawn: its component and placement. @@ -205,6 +241,7 @@ fn feature_row( depth: usize, fold: Option, mark: Mark, + drag: Option<&mut Option>>, ) -> bool { let Some(feature) = app.session.doc.features.get(index) else { return false }; let (component, path) = (here.component, here.path); @@ -280,6 +317,13 @@ fn feature_row( if let Some(at) = r.secondary { acts.menu = Some((at, if is_sketch { Target::Sketch { id } } else { Target::Feature { id } })); } + // In the History: a drag of the row (not of its fold arrow or eye) moves the feature (#9565). + if let Some(drag) = drag + && !r.on_control + && r.resp.as_ref().is_some_and(egui::Response::drag_started) + { + *drag = Some(crate::timeline_left::drag_features(app, id, selected)); + } r.fold } @@ -314,6 +358,44 @@ fn drag_marker(app: &mut SolveApp, ui: &egui::Ui, resp: &egui::Response, slots: } } +/// Dragging feature rows shows where they land with the History's drop bar; letting go moves them +/// with the History's own move, before or behind the marker by the side of it they land on. +fn drag_rows(app: &mut SolveApp, ui: &egui::Ui, plan: &HistoryRows, drag_id: egui::Id, dragged: Option>, drops: &[DropSlot], marker_y: f32) { + let Some(features) = dragged else { return }; + let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.any_down())); + if released || !down { + ui.data_mut(|d| d.remove::>(drag_id)); + } + if !released && !down { + return; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + // Only over the History's rows: let go anywhere else, nothing moves. + let reach = crate::ui_kit::TREE_ROW_HEIGHT; + let (top, bottom) = drops.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(top, bottom), drop| (top.min(drop.y), bottom.max(drop.y))); + let slot = ui + .ctx() + .pointer_latest_pos() + .filter(|p| ui.max_rect().x_range().contains(p.x) && p.y >= top - reach && p.y <= bottom + reach) + .and_then(|p| drops.iter().min_by(|a, b| (a.y - p.y).abs().total_cmp(&(b.y - p.y).abs())).copied()); + if released { + if let Some(drop) = slot { + crate::timeline_left::drop_items(app, plan, &features, &[], drop.group, drop.position, drop.y < marker_y); + } + return; + } + if let Some(drop) = slot { + let rect = ui.max_rect(); + crate::ui_kit::history_drop_bar( + ui.painter(), + Rect::from_min_max(pos2(rect.left() + 4.0, drop.y), pos2(rect.right() - 5.0, drop.y)), + true, + Tokens::get().sketch_accent, + ); + } + ui.data_mut(|d| d.insert_temp(drag_id, features)); +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { @@ -346,6 +428,48 @@ mod tests { assert!(h.app.session.visibility.hidden_bodies.is_empty()); } + #[test] + fn browser_feature_rows_stay_listed_after_manual_rollback() { + let mut h = Harness::new(); + for name in ["Before", "After"] { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"name":name})).unwrap(); + } + h.app.run("timeline.roll_to", json!({"position":1})).unwrap(); + for cache in ["current", "missing", "stale"] { + assert_eq!(feature_rows(&mut h, cache), ["Features", "Before", "After"], "{cache}"); + } + h.app.run("timeline.roll_to", json!({})).unwrap(); + for cache in ["current", "missing", "stale"] { + assert_eq!(feature_rows(&mut h, cache), ["Features", "Before", "After"], "{cache}"); + } + } + + #[test] + fn browser_feature_rows_follow_the_editor_and_restore_the_manual_marker() { + let mut h = Harness::new(); + let mut ids = Vec::new(); + for name in ["Before", "Edited", "After"] { + ids.push(h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"name":name})).unwrap()["feature"].as_u64().unwrap()); + } + h.app.run("timeline.roll_to", json!({"position":2})).unwrap(); + h.app.edit_feature(ids[1]); + assert!(h.app.dialog.as_ref().is_some_and(|d| d.editing.is_some())); + for cache in ["current", "missing", "stale"] { + assert_eq!(feature_rows(&mut h, cache), ["Features", "Before"], "{cache}"); + assert_eq!(shown_features(&h.app, true), 3, "history keeps all rows during editing"); + } + crate::dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc.marker, Some(2)); + for cache in ["current", "missing", "stale"] { + assert_eq!(feature_rows(&mut h, cache), ["Features", "Before", "Edited", "After"], "{cache}"); + } + h.app.edit_feature(ids[0]); + for cache in ["current", "missing", "stale"] { + assert!(feature_rows(&mut h, cache).is_empty(), "{cache}: no feature precedes the editor"); + } + crate::dialogs::cancel(&mut h.app); + } + #[test] fn browser_feature_sketch_only_has_one_sketch_row_and_no_features_folder() { let mut h = Harness::new(); @@ -871,4 +995,33 @@ mod tests { h.frames(2); assert_eq!(marks(), ["status:warning:Warned"], "a suppressed feature isn't built and has no mark"); } + + /// #9565: with the History in the browser, dragging a feature row moves the feature as the + /// History at the left does, and the marker still drags on its own. + #[test] + fn browser_history_rows_reorder_by_drag_and_the_marker_still_rolls_back() { + let mut h = Harness::new(); + let mut ids = Vec::new(); + for (i, name) in ["First", "Second", "Third"].into_iter().enumerate() { + let size = 5 + i as u64; + let r = h.app.run("solid.box", json!({"length":size,"width":size,"height":size,"name":name,"body_name":name})).unwrap(); + ids.push(r["feature"].as_u64().unwrap()); + } + h.app.run("view.history_layout", json!({"dock":"browser"})).unwrap(); + settle_heavy(&mut h); + let order = |h: &Harness| h.app.session.doc.features.iter().map(|f| f.id).collect::>(); + assert_eq!(order(&h), ids); + + let first = crate::scenario::handle_at(&format!("row:browser-feature:c0:{}", ids[0])).unwrap(); + h.step(&json!({"drag":[{"handle":format!("row:browser-feature:c0:{}", ids[2])},[first.x, first.y - 8.0]]})).unwrap(); + settle_heavy(&mut h); + assert_eq!(order(&h), [ids[2], ids[0], ids[1]], "dropped above the first row: the third feature moves to the top"); + assert_eq!(h.app.session.doc.marker, None, "dropped above the marker: still built"); + + let second = crate::scenario::handle_at(&format!("row:browser-feature:c0:{}", ids[1])).unwrap(); + h.step(&json!({"drag":[{"handle":"browser:rollback"},[second.x, second.y - 8.0]]})).unwrap(); + settle_heavy(&mut h); + assert_eq!(h.app.session.doc.marker, Some(2), "the marker drag still rolls back"); + assert_eq!(order(&h), [ids[2], ids[0], ids[1]], "rolling back moves no feature"); + } } diff --git a/crates/ui-egui/src/browser/point_cloud_tracks.rs b/crates/ui-egui/src/browser/point_cloud_tracks.rs new file mode 100644 index 0000000000..04e32694ce --- /dev/null +++ b/crates/ui-egui/src/browser/point_cloud_tracks.rs @@ -0,0 +1,219 @@ +//! What the Edit Point Cloud card's range tracks draw, worked out off the frame: each track's +//! bars over the whole span and over its loupe, and the values its ends snap to. A track asks +//! every frame; a worker thread answers the newest asks (on the web, where there are no threads, +//! they are answered at once), and until then the track shows its last answer. The view asks too, +//! for the values the coloured property takes over what is kept: the ramp's automatic range. + +use std::collections::HashMap; +use std::sync::{Arc, Condvar, Mutex}; + +use egui::Color32; +use solidcraft_engine::doc::point_cloud::{Filter, PALETTE, PLAIN, PointCloud, byte, height_ramp}; +use solidcraft_engine::doc::point_cloud_distribution::{Colour, Geometry, histogram, label_changes}; + +use super::range_track::Bars; +use crate::theme::model_color; + +/// Equal bands a ramp's bars are split into. +const RAMP_BANDS: usize = 32; + +/// How a track's bars are coloured. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Paint { + /// One colour. + One, + /// The ramp over `range` of `property`; painted at each value when the track is that property. + Ramp { property: String, range: [f64; 2] }, + /// The palette of a whole-numbered `property`. + Palette { property: String }, +} + +/// What a track asks for: its property's bars over the whole span and over the loupe, in `bins` +/// bins each, over what `filters` keep; and the values where `changes_of` changes along it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Ask { + pub property: String, + pub span: [f64; 2], + pub loupe: [f64; 2], + pub bins: usize, + pub paint: Paint, + pub filters: Vec, + pub changes_of: Option, +} + +/// A track's answer. +#[derive(Clone, Debug, Default)] +pub(crate) struct Answer { + pub(super) overview: Bars, + pub(super) loupe: Bars, + pub(super) changes: Arc>, + /// The least and the greatest value the property takes over what is kept. + pub extent: Option<[f64; 2]>, +} + +/// The cloud's data as far as the tracks are concerned: its points, columns and placement. +type DataKey = Vec; + +fn data_key(c: &PointCloud) -> DataKey { + let mut k = vec![Arc::as_ptr(c.points.values()).cast::() as u64, c.origin.x.to_bits(), c.origin.y.to_bits(), c.origin.z.to_bits()]; + k.extend(c.complex.columns.iter().map(|col| Arc::as_ptr(&col.values.0).cast::() as u64)); + k.extend(c.transform.iter().flatten().map(|v| v.to_bits())); + k +} + +/// What answers need of a cloud, worked out once for its data. +struct Prepared { + key: DataKey, + cloud: Arc, + geometry: Option>, + changes: HashMap<(String, String), Arc>>, +} + +impl Prepared { + fn answer(&mut self, ask: &Ask) -> Answer { + let c = &self.cloud; + let geometry = self.geometry.get_or_insert_with(|| Arc::new(Geometry::of(c))).clone(); + let (colour, colours, ramp) = match &ask.paint { + // Coloured by its own property, each value has its colour: the bars are one slot, + // painted along the ramp. + Paint::Ramp { property, range } if *property == ask.property => (Colour::One, vec![plain()], Some(*range)), + Paint::Ramp { property, range } => ( + Colour::Ramp { property, range: *range, bands: RAMP_BANDS }, + (0..RAMP_BANDS).map(|k| model_color(height_ramp((k as f64 + 0.5) / RAMP_BANDS as f64))).chain([plain()]).collect(), + None, + ), + Paint::Palette { property } => { + (Colour::Palette { property }, PALETTE.iter().map(|c| model_color(*c)).chain([plain(), plain()]).collect(), None) + } + Paint::One => (Colour::One, vec![plain()], None), + }; + let bars = |axis: [f64; 2]| { + let h = histogram(c, &geometry, &ask.property, axis, ask.bins, &colour, &ask.filters); + let extent = h.as_ref().and_then(|h| h.extent); + (Bars { axis, slots: h.map(|h| h.slots).unwrap_or_default(), colours: colours.clone(), ramp }, extent) + }; + let (overview, extent) = bars(ask.span); + let loupe = if ask.loupe == ask.span { overview.clone() } else { bars(ask.loupe).0 }; + let changes = match &ask.changes_of { + Some(label) => self + .changes + .entry((label.clone(), ask.property.clone())) + .or_insert_with(|| Arc::new(label_changes(c, &geometry, label, &ask.property))) + .clone(), + None => Arc::default(), + }; + Answer { overview, loupe, changes, extent } + } +} + +/// What has no colour of its own: the cloud's plain colour. +fn plain() -> Color32 { + model_color(PLAIN.map(byte)) +} + +#[derive(Default)] +struct Inner { + cloud: Option<(DataKey, Arc)>, + asked: HashMap, + answers: HashMap)>, +} + +#[derive(Default)] +struct Shared { + inner: Mutex, + asked: Condvar, +} + +/// The answers of one card's tracks; clones share them. +#[derive(Clone, Default)] +pub(crate) struct Tracks { + shared: Arc, + #[cfg(not(target_arch = "wasm32"))] + worker: Arc>, +} + +impl std::fmt::Debug for Tracks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Tracks") + } +} + +impl Tracks { + /// The last answer to the track `key` (maybe to an older ask), asking `ask` of `cloud` when + /// that is new. + pub(crate) fn answer(&self, ctx: &egui::Context, cloud: &PointCloud, key: &str, ask: Ask) -> Option> { + let Ok(mut inner) = self.shared.inner.lock() else { return None }; + let data = data_key(cloud); + if inner.cloud.as_ref().is_none_or(|(k, _)| *k != data) { + inner.cloud = Some((data, Arc::new(cloud.clone()))); + } + let last = inner.answers.get(key).cloned(); + let answered = last.as_ref().is_some_and(|(a, _)| *a == ask); + if !answered && inner.asked.get(key) != Some(&ask) { + inner.asked.insert(key.to_string(), ask); + drop(inner); + self.work(ctx); + } + last.map(|(_, a)| a) + } + + /// Whether the track `key` has an answer to its last ask. + #[cfg(test)] + pub(super) fn answered(&self, key: &str) -> bool { + self.shared.inner.lock().is_ok_and(|i| !i.asked.contains_key(key) && i.answers.contains_key(key)) + } + + #[cfg(not(target_arch = "wasm32"))] + fn work(&self, ctx: &egui::Context) { + self.shared.asked.notify_one(); + self.worker.get_or_init(|| { + let shared = Arc::downgrade(&self.shared); + let ctx = ctx.clone(); + let _ = std::thread::Builder::new().name("point-cloud-tracks".into()).spawn(move || work(&shared, &ctx)); + }); + } + + #[cfg(target_arch = "wasm32")] + fn work(&self, ctx: &egui::Context) { + let mut prepared = None; + while answer_one(&self.shared, &mut prepared) {} + ctx.request_repaint(); + } +} + +/// Answer one ask, if there is one. +fn answer_one(shared: &Shared, prepared: &mut Option) -> bool { + let (key, ask, data, cloud) = { + let Ok(mut inner) = shared.inner.lock() else { return false }; + let Some(key) = inner.asked.keys().next().cloned() else { return false }; + let Some(ask) = inner.asked.remove(&key) else { return false }; + let Some((data, cloud)) = inner.cloud.clone() else { return false }; + (key, ask, data, cloud) + }; + if prepared.as_ref().is_none_or(|p| p.key != data) { + *prepared = Some(Prepared { key: data, cloud, geometry: None, changes: HashMap::new() }); + } + let Some(p) = prepared.as_mut() else { return false }; + let answer = Arc::new(p.answer(&ask)); + if let Ok(mut inner) = shared.inner.lock() { + inner.answers.insert(key, (ask, answer)); + } + true +} + +/// The worker: answers asks as they come, until the card is gone. +#[cfg(not(target_arch = "wasm32"))] +fn work(shared: &std::sync::Weak, ctx: &egui::Context) { + let mut prepared = None; + loop { + let Some(s) = shared.upgrade() else { return }; + if answer_one(&s, &mut prepared) { + ctx.request_repaint(); + continue; + } + let Ok(inner) = s.inner.lock() else { return }; + if inner.asked.is_empty() { + let _ = s.asked.wait_timeout(inner, std::time::Duration::from_millis(500)); + } + } +} diff --git a/crates/ui-egui/src/browser/point_clouds.rs b/crates/ui-egui/src/browser/point_clouds.rs index 0e504e1bc0..2c17627f30 100644 --- a/crates/ui-egui/src/browser/point_clouds.rs +++ b/crates/ui-egui/src/browser/point_clouds.rs @@ -1,10 +1,13 @@ //! The Point Clouds folder of the Browser (#5151): an eye per cloud and for the folder, a menu, -//! and the Edit Point Cloud card (the file's units, the size of the dots, how they are coloured: -//! the file's colours, one colour, or by height between two heights), whose live edits of one -//! opening are one undo step. +//! and the Edit Point Cloud card: the file's units, the size of the dots, how the cloud is +//! coloured (its colours, one colour, a ramp over a property, its range on a track, or the +//! palette of a label, with its legend), and its filters (a range on a track, played with ▶ or +//! Space, or the values of a label picked in its legend). Each track shows how much of the cloud +//! lies at each value, over what the other filters keep. The live edits of one opening are one +//! undo step. use serde_json::{Value, json}; -use solidcraft_engine::doc::point_cloud::{ColorBy, PLAIN, PointCloud, UNITS, byte}; +use solidcraft_engine::doc::point_cloud::{COORDINATES, ColorBy, Filter, Keep, MAX_FILTERS, Names, PLAIN, PointCloud, UNITS, byte, label_color}; use solidcraft_engine::view::POINT_CLOUDS_FOLDER; use super::{Actions, Row, draw_row, is_open, panel_pos, toggle, toggle_folder}; @@ -12,18 +15,86 @@ use crate::SolveApp; use crate::context_menu::{Item, Target}; use crate::ui_kit; -/// The Edit Point Cloud card: the cloud, the undo depth when it opened, and the automatic -/// height range of the points it was worked out for (it sorts a sample of them). +#[path = "point_cloud_tracks.rs"] +pub(crate) mod point_cloud_tracks; +#[path = "range_track.rs"] +mod range_track; +use point_cloud_tracks::{Ask, Paint, Tracks}; +use range_track::{Part, Track, range_track}; + +/// Filters the GPU applies; the others are kept but not drawn. +pub const DRAWN_FILTERS: usize = solidcraft_gpu::view::FILTERS; + +/// The Edit Point Cloud card: the cloud, the undo depth when it opened, what it worked out once +/// for the cloud's data (the automatic height range), its tracks' bars, and a filter playing. #[derive(Clone, Debug)] pub struct CloudPanel { pub cloud: u64, pub(crate) depth: usize, auto: Option<(usize, [u64; 16], Option<[f64; 2]>)>, + tracks: Tracks, + pub play: Option, +} + +/// A range filter moving on its own: the part of it touched last moves forward (its upper end +/// unrolls, its band slides, its lower end wipes), across its track's loupe in [`PLAY_SECONDS`]. +/// It is written to the design once, when it stops. +#[derive(Clone, Debug)] +pub struct Play { + pub filter: usize, + part: Part, + /// When it last moved, and where it is exactly. + at: f64, + exact: [f64; 2], + /// The range now (a whole-numbered property's ends whole). + pub now: [f64; 2], +} + +/// How long playing a filter across its track's loupe takes, in seconds: zooming in slows it. +const PLAY_SECONDS: f64 = 20.0; + +/// The names of a cloud's tracks. +fn filter_key(cloud: u64, k: usize) -> String { + format!("point_cloud_{cloud}_filter_{k}") +} +fn colour_key(cloud: u64) -> String { + format!("point_cloud_{cloud}_colour") +} + +impl Play { + /// Start playing `part` of `range` within `span`, from the start again when it is at the end. + fn new(filter: usize, part: Part, [lo, hi]: [f64; 2], span: [f64; 2], at: f64) -> Play { + let exact = match part { + Part::High if hi >= span[1] => [lo, lo], + Part::Band if hi >= span[1] => [span[0], span[0] + hi - lo], + Part::Low if lo >= hi => [span[0], hi], + _ => [lo, hi], + }; + Play { filter, part, at, exact, now: exact } + } + /// Move on to time `now` at `speed` units a second; whether it reached the end. + fn step(&mut self, now: f64, speed: f64, span: [f64; 2], integer: bool) -> bool { + let d = speed * (now - self.at).max(0.0); + self.at = now; + let [lo, hi] = self.exact; + let (exact, end) = match self.part { + Part::High => ([lo, (hi + d).min(span[1])], hi + d >= span[1]), + Part::Low => ([(lo + d).min(hi), hi], lo + d >= hi), + Part::Band => { + let w = hi - lo; + let start = (lo + d).min(span[1] - w); + ([start, start + w], lo + d >= span[1] - w) + } + }; + self.exact = exact; + self.now = if integer { exact.map(f64::floor) } else { exact }; + end + } } impl CloudPanel { pub fn new(app: &SolveApp, cloud: u64) -> Self { - CloudPanel { cloud, depth: app.session.undo.len(), auto: None } + CloudPanel { cloud, depth: app.session.undo.len(), auto: None, tracks: Tracks::default(), play: None } } /// The cloud's automatic height range, worked out again only when its points or placement /// change. @@ -38,11 +109,80 @@ impl CloudPanel { } } } + /// The range filter `k` of this card's cloud shows now, while it plays. + pub fn playing(&self, cloud: u64, k: usize) -> Option<[f64; 2]> { + self.play.as_ref().filter(|p| self.cloud == cloud && p.filter == k).map(|p| p.now) + } } /// The ways the card colours the points, as the dropdown lists them. const COLOR_BY: [(ColorBy, &str); 3] = [(ColorBy::File, "File Colors"), (ColorBy::Single, "Single Color"), (ColorBy::Height, "Height")]; +/// The names of `property` when it is filtered by a set of values: a label whose values span +/// fewer than the 32 the view's mask holds. Any other property is filtered by a range. +fn label<'a>(c: &'a PointCloud, property: &str) -> Option<&'a Names> { + let names = c.complex.names(property)?; + let (lo, hi) = names.values.iter().fold((i32::MAX, i32::MIN), |(lo, hi), (v, _)| (lo.min(*v), hi.max(*v))); + (i64::from(hi) - i64::from(lo) < 32).then_some(names) +} + +/// Bins of a track's bars: about one a point across the card. +fn bins(ui: &egui::Ui) -> usize { + (ui.available_width().round() as usize).clamp(16, 1024) +} + +/// The filters as `pointcloud.edit` takes them. +fn filters_json(filters: &[Filter]) -> Vec { + filters.iter().map(|f| serde_json::to_value(f).unwrap_or(Value::Null)).collect() +} + +/// The filters with `value` of the label `property` shown or hidden: in its set filter, or in a +/// new one keeping every named value but it. +fn toggled(c: &PointCloud, names: &Names, value: i32) -> Vec { + let mut filters = c.filters.clone(); + match filters.iter_mut().find(|f| f.property == names.property && matches!(f.keep, Keep::Values { .. })) { + Some(Filter { keep: Keep::Values { values }, .. }) => { + if let Some(k) = values.iter().position(|v| *v == value) { + values.remove(k); + } else { + values.push(value); + } + } + _ => filters.push(Filter { + property: names.property.clone(), + keep: Keep::Values { values: names.values.iter().map(|(v, _)| *v).filter(|v| *v != value).collect() }, + }), + } + filters_json(&filters) +} + +/// A label's legend: a swatch and a name per value, dimmed when a filter hides it. The value +/// clicked, to show or hide. +fn legend(ui: &mut egui::Ui, c: &PointCloud, names: &Names) -> Option { + let kept = c.filters.iter().find_map(|f| match &f.keep { + Keep::Values { values } if f.property == names.property => Some(values.clone()), + _ => None, + }); + let mut clicked = None; + for (v, name) in &names.values { + let shown = kept.as_ref().is_none_or(|k| k.contains(v)); + let colour = crate::theme::model_color(label_color(f64::from(*v))); + let swatch = if shown { colour } else { colour.gamma_multiply(0.16) }; + let response = ui + .horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 3.0, swatch); + let text = egui::RichText::new(name.as_str()); + ui.add(egui::Label::new(if shown { text } else { text.weak().strikethrough() }).sense(egui::Sense::click())) + }) + .inner; + if response.clicked() { + clicked = Some(*v); + } + } + clicked +} + pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) { let clouds: Vec<(u64, String, bool)> = app.session.doc.point_clouds.iter().map(|c| (c.id, c.name.clone(), c.visible)).collect(); if clouds.is_empty() { @@ -104,8 +244,29 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) { }; let mut edit: Option = None; let title = crate::i18n::format("browser.edit_point_cloud_title", &[("name", &c.name)]); - let (units, decimals) = (app.session.doc.units.clone(), app.preferences.length_decimals); let auto = if c.color_by == ColorBy::Height { m.auto_range(&c) } else { None }; + let now = ctx.input(|i| i.time); + // A playing filter moves on; at the end of its span it stops, and is written once. + let mut stop_play = false; + if let Some(p) = m.play.as_mut() + && let Some(span) = c.filters.get(p.filter).and_then(|f| c.span(&f.property)) + { + let integer = c.filters.get(p.filter).is_some_and(|f| c.complex.integer(&f.property)); + let [a, b] = range_track::loupe(ctx, &filter_key(c.id, p.filter), span); + stop_play = p.step(now, (b - a) / PLAY_SECONDS, span, integer); + ctx.request_repaint(); + } + // The cloud as the view draws it now, a playing filter where it is: the filters the tracks + // apply, how their bars are coloured, as the cloud is, and the values they snap to, where a + // label changes. + let live = crate::point_clouds::as_drawn(&c, |k| m.playing(c.id, k)).into_owned(); + let drawn: Vec = live.filters.iter().take(DRAWN_FILTERS).cloned().collect(); + let paint = match (c.colored_property(), crate::point_clouds::color_range(ctx, &live)) { + (Some(n), _) if c.complex.integer(n) => Paint::Palette { property: n.to_string() }, + (Some(n), Some(range)) => Paint::Ramp { property: n.to_string(), range }, + _ => Paint::One, + }; + let changes_of = c.colored_property().filter(|n| c.complex.integer(n)).map(str::to_string); let at = panel_pos(app); let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_point_cloud_panel"), "point", &title) .placed(egui::Align2::LEFT_TOP, at) @@ -113,29 +274,39 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) { .actions("", crate::i18n::tr("ui.close"), None) .show(ctx, true, |ui| { ui_kit::row(ui, crate::i18n::tr_label("Points"), |ui| ui.label(c.len().to_string())); - let names: Vec<&str> = UNITS.iter().map(|(u, _)| *u).collect(); - let was = names.iter().position(|u| *u == c.units).unwrap_or(0); - let mut unit = was; - // A dropdown's response doesn't report the pick: compare the index. - ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &names)); - if unit != was - && let Some(u) = names.get(unit) - { - edit = Some(json!({ "cloud": c.id, "units": u })); - } let mut size = c.point_size; if ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_size"), |ui| ui_kit::slider(ui, &mut size, 0.5..=16.0, Default::default())).changed() { edit = Some(json!({ "cloud": c.id, "point_size": size })); } - let labels: Vec<&str> = COLOR_BY.iter().map(|(_, l)| *l).collect(); - let was = COLOR_BY.iter().position(|(b, _)| *b == c.color_by).unwrap_or(0); + let units: Vec<&str> = UNITS.iter().map(|(u, _)| *u).collect(); + let was = units.iter().position(|u| *u == c.units).unwrap_or(0); + let mut unit = was; + // A dropdown's response doesn't report the pick: compare the index. + ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &units)); + if unit != was + && let Some(u) = units.get(unit) + { + edit = Some(json!({ "cloud": c.id, "units": u })); + } + // The ways of colouring, then every property of the file. + let properties: Vec<&str> = c.property_names().filter(|n| !COORDINATES.contains(n)).collect(); + let labels: Vec<&str> = COLOR_BY.iter().map(|(_, l)| *l).chain(properties.iter().copied()).collect(); + let was = match c.color_by { + ColorBy::Property => { + c.color_property.as_deref().and_then(|n| properties.iter().position(|p| *p == n)).map_or(0, |k| COLOR_BY.len() + k) + } + by => COLOR_BY.iter().position(|(b, _)| *b == by).unwrap_or(0), + }; let mut by = was; ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_color_by"), |ui| ui_kit::dropdown(ui, "point_cloud_color_by", &mut by, &labels)); - if by != was - && let Some((b, _)) = COLOR_BY.get(by) - { - edit = Some(json!({ "cloud": c.id, "color_by": b.name() })); + if by != was { + let name = COLOR_BY.get(by).map(|(b, _)| b.name()).or_else(|| properties.get(by - COLOR_BY.len()).copied()); + if let Some(name) = name { + edit = Some(json!({ "cloud": c.id, "color_by": name })); + } } + let colored = c.colored_property(); + let legend_shown = colored.and_then(|n| label(&c, n)); match c.color_by { ColorBy::File if c.colors.is_empty() => { ui_kit::row(ui, "", |ui| ui.weak(crate::i18n::tr("ui.point_cloud_no_file_colors"))); @@ -147,33 +318,201 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) { edit = Some(json!({ "cloud": c.id, "color": rgb.map(|v| f64::from(v) / 255.0) })); } } - ColorBy::Height => { - let automatic = c.height_range.is_none(); - if ui_kit::checkbox_row(ui, "point_cloud_auto_range", crate::i18n::tr("ui.point_cloud_auto_range"), automatic) { - // Off: start from the range worked out, which the fields then show. - edit = Some(json!({ "cloud": c.id, "height_range": if automatic { json!(auto) } else { Value::Null } })); - } - if let Some([mut lo, mut hi]) = c.height_range.or(auto) { - let mut changed = false; - ui.add_enabled_ui(!automatic, |ui| { - changed |= ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_height_low"), |ui| { - ui_kit::length_field(ui, &mut lo, &units, decimals) - }) - .changed(); - changed |= ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_height_high"), |ui| { - ui_kit::length_field(ui, &mut hi, &units, decimals) - }) - .changed(); - }); - // The two heights stay apart, lowest first. - if changed && !automatic { - let hi = hi.max(lo + 1e-3); - edit = Some(json!({ "cloud": c.id, "height_range": [lo, hi] })); + ColorBy::Height | ColorBy::Property => { + let name = colored.unwrap_or_default(); + if let Some(names) = legend_shown { + // A label: its legend, which also shows and hides its values. + if let Some(v) = legend(ui, &c, names) { + edit = Some(json!({ "cloud": c.id, "filters": toggled(&c, names, v) })); + } + } else if !c.complex.integer(name) + && let Some(span) = if c.color_by == ColorBy::Height { c.span("z").or(auto) } else { c.span(name) } + { + // A ramp over what is shown, unless its range is set. + let automatic = c.height_range.is_none(); + if ui_kit::checkbox_row(ui, "point_cloud_auto_range", crate::i18n::tr("ui.point_cloud_auto_range"), automatic) { + edit = Some(json!({ "cloud": c.id, "color_range": if automatic { json!(crate::point_clouds::color_range(ui.ctx(), &live)) } else { Value::Null } })); + } + // The ramp's range is a range on a track of the property, over what the + // filters keep; a double click beside it gives it back to the filters. + let shown = crate::point_clouds::color_range(ui.ctx(), &live).unwrap_or(span); + let key = colour_key(c.id); + let ask = Ask { + property: name.to_string(), + span, + loupe: range_track::loupe(ui.ctx(), &key, span), + bins: bins(ui), + paint: Paint::Ramp { property: name.to_string(), range: shown }, + filters: drawn.clone(), + changes_of: None, + }; + let answer = m.tracks.answer(ui.ctx(), &c, &key, ask); + let out = range_track( + ui, + &Track { + key: &key, + range: shown, + span, + integer: false, + overview: answer.as_ref().map(|a| &a.overview), + loupe: answer.as_ref().map(|a| &a.loupe), + changes: &[], + // An automatic range moves with a playing filter: its moving end + // stays in the loupe. + follow: m.play.as_ref().filter(|_| automatic).map(|p| if p.part == Part::Low { shown[0] } else { shown[1] }), + }, + ); + if let Some(range) = out.range { + edit = Some(json!({ "cloud": c.id, "color_range": range })); + } else if out.whole { + edit = Some(json!({ "cloud": c.id, "color_range": Value::Null })); } } } } + // Filters: only what every one of them keeps is shown. + let mut filters = c.filters.clone(); + let mut changed = false; + let mut removed = None; + for (k, f) in c.filters.iter().enumerate() { + let span = c.span(&f.property); + let integer = c.complex.integer(&f.property); + // Play or stop, by ▶ or Space on the track. + let mut toggle = None; + ui.horizontal(|ui| { + ui.label(crate::i18n::tr_dyn(&format!("Filter: {}", f.property))); + let playing = m.play.as_ref().is_some_and(|p| p.filter == k); + if let (Some(range), Some(span)) = (f.range(), span) + && ui_kit::button( + ui, + if playing { + format!("⏸ {}", crate::i18n::tr_label("Pause")) + } else { + format!("▶ {}", crate::i18n::tr_label("Play")) + }, + ui_kit::Button::Small, + ) + .clicked() + { + toggle = Some((m.playing(c.id, k).unwrap_or(range), span)); + } + if ui_kit::button(ui, "×", ui_kit::Button::Small).clicked() { + removed = Some(k); + } + }); + if k >= DRAWN_FILTERS { + ui.weak(crate::i18n::tr_dyn(&format!("Not drawn: the view applies {DRAWN_FILTERS} filters at most"))); + } + match (&f.keep, span) { + (Keep::Values { .. }, _) => match label(&c, &f.property) { + Some(names) if legend_shown.is_some_and(|l| l.property == names.property) => { + ui.weak(crate::i18n::tr_label("Shown and hidden in the legend above")); + } + Some(names) => { + if let Some(v) = legend(ui, &c, names) { + edit = Some(json!({ "cloud": c.id, "filters": toggled(&c, names, v) })); + } + } + None => {} + }, + (Keep::Range { range }, Some(span)) => { + let shown = m.playing(c.id, k).unwrap_or(*range); + let key = filter_key(c.id, k); + // The track shows what the other filters keep. + let others = drawn.iter().enumerate().filter(|(j, _)| *j != k).map(|(_, f)| f.clone()).collect(); + let ask = Ask { + property: f.property.clone(), + span, + loupe: range_track::loupe(ui.ctx(), &key, span), + bins: bins(ui), + paint: paint.clone(), + filters: others, + changes_of: changes_of.clone(), + }; + let answer = m.tracks.answer(ui.ctx(), &c, &key, ask); + let moving = m.play.as_ref().filter(|p| p.filter == k).map(|p| if p.part == Part::Low { shown[0] } else { shown[1] }); + let out = range_track( + ui, + &Track { + key: &key, + range: shown, + span, + integer, + overview: answer.as_ref().map(|a| &a.overview), + loupe: answer.as_ref().map(|a| &a.loupe), + changes: answer.as_ref().map_or(&[][..], |a| &a.changes[..]), + follow: moving, + }, + ); + let moved = out.range.or(out.whole.then_some(span)); + if let Some(range) = moved + && let Some(slot) = filters.get_mut(k) + { + slot.keep = Keep::Range { range }; + changed = true; + // Moving it by hand stops it playing. + if m.play.as_ref().is_some_and(|p| p.filter == k) { + m.play = None; + } + } + if out.play { + toggle = Some((moved.unwrap_or(shown), span)); + } + } + (Keep::Range { .. }, None) => {} + } + if let Some((range, span)) = toggle { + if m.play.as_ref().is_some_and(|p| p.filter == k) { + stop_play = true; + } else { + // Another filter playing stops where it is. + if let Some(p) = m.play.take() + && let Some(slot) = filters.get_mut(p.filter) + { + slot.keep = Keep::Range { range: p.now }; + changed = true; + } + let part = range_track::last_part(ui.ctx(), &filter_key(c.id, k)); + m.play = Some(Play::new(k, part, range, span, now)); + } + } + } + if let Some(k) = removed { + filters.remove(k); + changed = true; + m.play = None; + } + let all: Vec<&str> = c.property_names().collect(); + if c.filters.len() < MAX_FILTERS { + let mut add_labels = vec![crate::i18n::tr_label("Add filter…")]; + add_labels.extend(all.iter().copied()); + let mut pick = 0; + ui_kit::row(ui, crate::i18n::tr_label("Filter by"), |ui| ui_kit::dropdown(ui, "point_cloud_add_filter", &mut pick, &add_labels)); + if let Some(name) = all.get(pick.wrapping_sub(1)) { + // A label keeps all its values at first, anything else its whole span. + let keep = match (label(&c, name), c.span(name)) { + (Some(names), _) => Some(Keep::Values { values: names.values.iter().map(|(v, _)| *v).collect() }), + (None, Some(span)) => Some(Keep::Range { range: span }), + (None, None) => None, + }; + if let Some(keep) = keep { + filters.push(Filter { property: name.to_string(), keep }); + changed = true; + } + } + } + if changed { + edit = Some(json!({ "cloud": c.id, "filters": filters_json(&filters) })); + } }); + // A filter that stops playing is written where it stopped. + if stop_play && let Some(p) = m.play.take() { + let mut filters = c.filters.clone(); + if let Some(f) = filters.get_mut(p.filter) { + f.keep = Keep::Range { range: p.now }; + edit = Some(json!({ "cloud": c.id, "filters": filters_json(&filters) })); + } + } if let Some(p) = edit && app.run("pointcloud.edit", p).is_ok() { @@ -225,4 +564,96 @@ mod tests { assert_eq!(h.app.session.doc.point_clouds.len(), 1); let _ = std::fs::remove_dir_all(dir); } + + /// A PLY file of points along x, `t` running 0 to 10 by halves, a layer every 2: the layer + /// changes at t = 2, 4, 6 and 8. + fn layered_points(dir: &std::path::Path) -> std::path::PathBuf { + let mut ply = String::from( + "ply\nformat ascii 1.0\nelement vertex 21\nproperty float x\nproperty float y\nproperty float z\nproperty float t\nproperty int layer\nend_header\n", + ); + for i in 0..=20 { + let t = f64::from(i) / 2.0; + ply += &format!("{t} 0 0 {t} {}\n", (i / 4).min(4)); + } + let path = dir.join("layers.ply"); + std::fs::write(&path, ply).unwrap(); + path + } + + #[test] + fn a_filter_track_draws_snaps_steps_frames_and_plays() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let dir = std::env::temp_dir().join(format!("solidcraft-browser-track-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = layered_points(&dir); + let id = h.app.run("pointcloud.insert", json!({"path": path.to_string_lossy()})).unwrap()["cloud"].as_u64().unwrap(); + h.app.run("pointcloud.edit", json!({"cloud": id, "color_by": "layer", "filters": [{"property": "t", "range": [2.0, 5.0]}]})).unwrap(); + h.app.tree.cloud_panel = Some(super::CloudPanel::new(&h.app, id)); + let key = super::filter_key(id, 0); + let range = |h: &crate::scenario::Harness| h.app.session.doc.point_clouds[0].filters[0].range().unwrap(); + // The bars come from the worker: wait for them, and for the layer changes to snap to. + let mut bars = None; + for _ in 0..200 { + h.frames(1); + bars = crate::scenario::rect_of(&format!("range_track:{key}:bars")); + if bars.is_some() && h.app.tree.cloud_panel.as_ref().is_some_and(|m| m.tracks.answered(&key)) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.frames(2); + let r = bars.expect("the track is drawn"); + let x = |t: f64| f64::from(r.left()) + t / 10.0 * f64::from(r.width()); + let y = f64::from(r.center().y); + let drag = |h: &mut crate::scenario::Harness, a: f64, b: f64| { + h.call("ui.drag", json!({"x0": x(a), "y0": y, "x1": x(b), "y1": y, "steps": 8})); + h.frames(2); + }; + // Drawn beside the range: a new range, its start snapped to the layer change at 6. + drag(&mut h, 6.04, 8.9); + let [lo, hi] = range(&h); + assert!(lo == 6.0 && (hi - 8.9).abs() < 0.05, "{lo} {hi}"); + // Its end dragged near 8 snaps to it; its start, far from any change, does not. + drag(&mut h, 8.9, 7.97); + drag(&mut h, 6.0, 6.5); + let [lo, hi] = range(&h); + assert!((lo - 6.5).abs() < 0.05 && hi == 8.0, "{lo} {hi}"); + // ← steps the start, touched last, to the change before it, then the one before that. + for _ in 0..2 { + h.call("ui.key", json!({"key": "ArrowLeft"})); + h.frames(2); + } + assert_eq!(range(&h), [4.0, 8.0]); + // A double click on the range frames the loupe on it; beside it, the whole span. + h.call("ui.click", json!({"x": x(6.0), "y": y, "double": true})); + h.frames(2); + let [a, b] = super::range_track::loupe(&h.ctx, &key, [0.0, 10.0]); + assert!(a > 3.0 && a < 4.0 && b > 8.0 && b < 9.0, "{a} {b}"); + // A double click on the strip above shows the whole span again; beside the range, the + // range becomes the whole span. + // (Past the double click's time, so that it is not a third click.) + h.frames(30); + let strip = crate::scenario::rect_of(&format!("range_track:{key}:strip")).unwrap(); + h.call("ui.click", json!({"x": f64::from(strip.center().x), "y": f64::from(strip.center().y), "double": true})); + h.frames(2); + assert_eq!(super::range_track::loupe(&h.ctx, &key, [0.0, 10.0]), [0.0, 10.0]); + h.frames(30); + h.call("ui.click", json!({"x": x(9.5), "y": y, "double": true})); + h.frames(2); + assert_eq!(range(&h), [0.0, 10.0]); + // Space plays the part touched last (here the end, unrolling), written once when it stops. + drag(&mut h, 10.0, 7.0); + assert!((range(&h)[1] - 7.0).abs() < 0.05); + h.call("ui.key", json!({"key": "Space"})); + h.frames(2); + assert!(h.app.tree.cloud_panel.as_ref().unwrap().play.is_some()); + h.frames(20); + h.call("ui.key", json!({"key": "Space"})); + h.frames(2); + assert!(h.app.tree.cloud_panel.as_ref().unwrap().play.is_none()); + let [lo, hi] = range(&h); + assert!(lo == 0.0 && hi > 7.0 && hi < 10.0, "played on from 7: {hi}"); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/crates/ui-egui/src/browser/range_track.rs b/crates/ui-egui/src/browser/range_track.rs new file mode 100644 index 0000000000..e399be4727 --- /dev/null +++ b/crates/ui-egui/src/browser/range_track.rs @@ -0,0 +1,580 @@ +//! A range on a track, as in an audio editor. The track draws how much of the cloud lies at each +//! value (its bars) under the range. A thin strip above shows the whole span, with the part the +//! track shows (its loupe) framed: the strip is the same range widget, its range being the loupe. +//! +//! Gestures: drag a range's end or its band to move them; drag elsewhere to draw a new range; +//! Shift-click to bring the nearer end to the pointer; double-click the band to frame the loupe on +//! it, elsewhere to ask for the whole (`Outcome::whole`); the wheel or a pinch zooms the loupe +//! around the pointer, a sideways scroll moves it. An end snaps to a value where the colour +//! changes when that value stands alone at this zoom (nothing else within twice the reach), and +//! a whole-numbered property's ends to whole numbers; ⌥ moves ten times slower, without +//! snapping. With the keyboard, once the track is touched: ← and → bring the part touched last to +//! the previous or next change (one pixel with ⌥ or where there is none), Space asks to play it. +//! The ends and the length are fields too; a field's value may be typed relative, as `+30`. + +use egui::{Color32, Mesh, Pos2, Rect, Sense, Stroke, Ui, vec2}; + +use crate::ui_kit; + +use crate::theme::Tokens; + +/// The part of a range a gesture moves, and that playing moves. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Part { + Low, + #[default] + High, + Band, +} + +/// How much lies at each value of an axis: `slots[s][b]`, stacked in slot order, slot `s` in +/// `colours[s]`; or, with `ramp`, one slot painted at each value with the ramp over that range. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct Bars { + pub axis: [f64; 2], + pub slots: Vec>, + pub colours: Vec, + pub ramp: Option<[f64; 2]>, +} + +/// What a track shows and snaps to. +pub(super) struct Track<'a> { + /// Names the track among all of them. + pub key: &'a str, + pub range: [f64; 2], + pub span: [f64; 2], + pub integer: bool, + pub overview: Option<&'a Bars>, + pub loupe: Option<&'a Bars>, + /// Values the ends snap to and the arrows step through, lowest first. + pub changes: &'a [f64], + /// A value moving on its own (a range playing), kept in the loupe. + pub follow: Option, +} + +/// What a frame's input asks for. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct Outcome { + /// The range moved. + pub range: Option<[f64; 2]>, + /// The whole: a double click beside the range. + pub whole: bool, + /// Play or stop: Space. + pub play: bool, + /// The range was touched by a gesture, a field or a key. + pub touched: bool, +} + +/// What a drag moves: an end, the band, or a new range from where it started. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grab { + Low, + High, + Band, + New(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Drag { + /// On the strip (moving the loupe) or the track (moving the range). + strip: bool, + grab: Grab, + /// The range when it started, and the value under the pointer now (slowed by ⌥). + start: [f64; 2], + at: f64, + from: f64, + last_x: f32, +} + +/// What the track keeps between frames. +#[derive(Clone, Copy, Debug, Default)] +struct State { + loupe: Option<[f64; 2]>, + drag: Option, + last: Part, + /// Where an end snapped during the drag. + snapped: Option, +} + +/// The loupe's narrowest width, as a fraction of the span. +const MIN_LOUPE: f64 = 1e-6; +/// Zoom per point of wheel scroll. +const ZOOM: f64 = 0.004; +/// How close to an end a press grabs it, and an end snaps, in points. +const REACH: f32 = 6.0; +/// ⌥'s slowing. +const FINE: f64 = 0.1; +const STRIP: f32 = 12.0; +const RULER: f32 = 14.0; +const BARS: f32 = 40.0; + +fn id(key: &str) -> egui::Id { + egui::Id::new(("sc_range_track", key)) +} + +fn state(ctx: &egui::Context, key: &str) -> State { + ctx.data(|d| d.get_temp(id(key))).unwrap_or_default() +} + +fn whole(span: [f64; 2]) -> [f64; 2] { + [span[0], span[1].max(span[0] + 1e-9)] +} + +/// The part of `span` a track shows now. +pub(super) fn loupe(ctx: &egui::Context, key: &str, span: [f64; 2]) -> [f64; 2] { + state(ctx, key).loupe.map_or(whole(span), |l| fit(l, whole(span))) +} + +/// The part of a track's range touched last. +pub(super) fn last_part(ctx: &egui::Context, key: &str) -> Part { + state(ctx, key).last +} + +/// `l` moved and narrowed to lie within `span`, at least its narrowest. +fn fit([a, b]: [f64; 2], span: [f64; 2]) -> [f64; 2] { + let full = span[1] - span[0]; + let w = (b - a).clamp(full * MIN_LOUPE, full); + let left = (a + (b - a - w) / 2.0).clamp(span[0], span[1] - w); + [left, left + w] +} + +/// The change nearest `v` within `r`, when no other change is within `2r` of it. +fn lone_change(changes: &[f64], v: f64, r: f64) -> Option { + let i = changes.partition_point(|c| *c < v); + let near = [i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|k| changes.get(k).map(|c| (k, *c))); + let (k, c) = near.min_by(|a, b| (a.1 - v).abs().total_cmp(&(b.1 - v).abs()))?; + let alone = k.checked_sub(1).and_then(|j| changes.get(j)).is_none_or(|p| c - p > 2.0 * r) && changes.get(k + 1).is_none_or(|n| n - c > 2.0 * r); + ((c - v).abs() <= r && alone).then_some(c) +} + +/// The changes standing alone at this zoom within `[a, b]`, at most `n`. +fn lone_changes(changes: &[f64], [a, b]: [f64; 2], r: f64, n: usize) -> Vec { + let (i, j) = (changes.partition_point(|c| *c < a), changes.partition_point(|c| *c <= b)); + (i..j) + .filter(|k| { + let c = changes.get(*k).copied().unwrap_or(f64::NAN); + k.checked_sub(1).and_then(|p| changes.get(p)).is_none_or(|p| c - p > 2.0 * r) && changes.get(k + 1).is_none_or(|q| q - c > 2.0 * r) + }) + .filter_map(|k| changes.get(k).copied()) + .take(n) + .collect() +} + +/// The next change after `v` (`dir` 1) or before it (-1). +fn next_change(changes: &[f64], v: f64, dir: f64) -> Option { + let eps = v.abs().max(1.0) * 1e-12; + if dir > 0.0 { + changes.get(changes.partition_point(|c| *c <= v + eps)).copied() + } else { + changes.partition_point(|c| *c < v - eps).checked_sub(1).and_then(|k| changes.get(k)).copied() + } +} + +/// Bar and tick steps: 1, 2 or 5 times a power of ten, at least `min` apart. +fn nice_step(min: f64) -> f64 { + let k = 10f64.powf(min.max(1e-300).log10().floor()); + [1.0, 2.0, 5.0, 10.0].into_iter().map(|m| m * k).find(|s| *s >= min).unwrap_or(10.0 * k) +} + +/// Paint `bars` within `rect`, showing `axis`; each column scaled to the tallest shown. +fn paint_bars(painter: &egui::Painter, rect: Rect, axis: [f64; 2], bars: &Bars, fill: Color32, ink: Color32) { + painter.rect_filled(rect, 2.0, fill); + let n = bars.slots.first().map_or(0, Vec::len); + if n == 0 || !(bars.axis[1] > bars.axis[0]) { + return; + } + let width = (bars.axis[1] - bars.axis[0]) / n as f64; + let x_of = |v: f64| rect.left() + ((v - axis[0]) / (axis[1] - axis[0])) as f32 * rect.width(); + let total = |b: usize| bars.slots.iter().filter_map(|s| s.get(b)).sum::(); + let shown: Vec = (0..n) + .filter(|b| { + let lo = bars.axis[0] + *b as f64 * width; + lo + width >= axis[0] && lo <= axis[1] + }) + .collect(); + let peak = shown.iter().map(|b| total(*b)).fold(0.0, f64::max); + if !(peak > 0.0) { + return; + } + let mut mesh = Mesh::default(); + for b in shown { + let lo = bars.axis[0] + b as f64 * width; + let (x0, x1) = (x_of(lo).max(rect.left()), x_of(lo + width).min(rect.right()).max(x_of(lo).max(rect.left()) + 1.0)); + let mut y = rect.bottom(); + for (s, slot) in bars.slots.iter().enumerate() { + let h = (slot.get(b).copied().unwrap_or(0.0) / peak) as f32 * (rect.height() - 2.0); + if h <= 0.0 { + continue; + } + let colour = match bars.ramp { + Some([a, z]) if z > a => { + crate::theme::model_color(solidcraft_engine::doc::point_cloud::height_ramp((lo + width / 2.0 - a) / (z - a))) + } + _ => bars.colours.get(s).copied().unwrap_or(ink), + }; + mesh.add_colored_rect(Rect::from_min_max(Pos2::new(x0, y - h), Pos2::new(x1, y)), colour); + y -= h; + } + } + painter.add(mesh); +} + +/// The range within `span` after this frame's input, and what else the input asks for. +pub(super) fn range_track(ui: &mut Ui, track: &Track) -> Outcome { + let t = Tokens::get(); + let ctx = ui.ctx().clone(); + let mut st = state(&ctx, track.key); + let span = whole(track.span); + let mut out = Outcome::default(); + let mut range = track.range; + let mut view = st.loupe.map_or(span, |l| fit(l, span)); + if let Some(f) = track.follow + && (f < view[0] || f > view[1]) + { + let w = view[1] - view[0]; + view = fit([f - 0.1 * w, f + 0.9 * w], span); + st.loupe = Some(view); + } + + let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), STRIP + RULER + BARS + 4.0), Sense::click_and_drag()); + let inner = rect.shrink2(vec2(REACH, 0.0)); + let strip = Rect::from_min_size(inner.min, vec2(inner.width(), STRIP)); + let ruler = Rect::from_min_size(Pos2::new(inner.left(), strip.bottom() + 2.0), vec2(inner.width(), RULER)); + let bars = Rect::from_min_size(Pos2::new(inner.left(), ruler.bottom() + 2.0), vec2(inner.width(), BARS)); + // Named for agents and tests: the strip and the bars. + crate::scenario::publish_rect(&format!("range_track:{}:strip", track.key), strip); + crate::scenario::publish_rect(&format!("range_track:{}:bars", track.key), bars); + let on_strip = |p: Pos2| p.y < strip.bottom() + 1.0; + let axis_of = |strip: bool, view: [f64; 2]| if strip { span } else { view }; + let x_of = |axis: [f64; 2], v: f64| inner.left() + ((v - axis[0]) / (axis[1] - axis[0])) as f32 * inner.width(); + let v_of = |axis: [f64; 2], x: f32| axis[0] + f64::from((x - inner.left()) / inner.width().max(1.0)) * (axis[1] - axis[0]); + let per_point = (view[1] - view[0]) / f64::from(inner.width().max(1.0)); + let reach = f64::from(REACH) * per_point; + let mods = ui.input(|i| i.modifiers); + let snap = |v: f64, fine: bool, snapped: &mut Option| { + let s = if fine { None } else { lone_change(track.changes, v, reach) }; + *snapped = s; + let v = s.unwrap_or(v); + if track.integer { v.round() } else { v } + }; + let grab_at = |strip: bool, x: f32, view: [f64; 2], range: [f64; 2]| { + let (axis, [a, b]) = (axis_of(strip, view), if strip { view } else { range }); + let (xa, xb) = (x_of(axis, a), x_of(axis, b)); + if (x - xa).abs().min((x - xb).abs()) <= REACH { + Some(if (x - xa).abs() < (x - xb).abs() { Grab::Low } else { Grab::High }) + } else if x > xa && x < xb { + Some(Grab::Band) + } else { + None + } + }; + + // Zoom and move the loupe. + if response.hovered() + && let Some(p) = response.hover_pos() + { + let (zoom, sideways, pinch) = crate::navigation::axis_wheel(&ctx, rect.height()); + if sideways != 0.0 { + let d = -f64::from(sideways) * per_point; + view = fit([view[0] + d, view[1] + d], span); + st.loupe = Some(view); + } + if zoom != 0.0 || pinch != 1.0 { + let k = (-f64::from(zoom) * ZOOM).exp() / f64::from(pinch); + let at = v_of(axis_of(on_strip(p), view), p.x).clamp(view[0], view[1]); + view = fit([at - (at - view[0]) * k, at + (view[1] - at) * k], span); + st.loupe = Some(view); + } + } + if response.drag_started() + && let Some(p) = ui.input(|i| i.pointer.press_origin()) + { + response.request_focus(); + let strip = on_strip(p); + let axis = axis_of(strip, view); + let v = v_of(axis, p.x); + let start = if strip { view } else { range }; + let grab = match grab_at(strip, p.x, view, range) { + Some(g) => g, + None if mods.shift => { + if (v - start[0]).abs() < (v - start[1]).abs() { + Grab::Low + } else { + Grab::High + } + } + None => Grab::New(if strip { v } else { snap(v, mods.alt, &mut st.snapped) }), + }; + if !strip { + st.last = match grab { + Grab::Low => Part::Low, + Grab::Band => Part::Band, + Grab::High | Grab::New(_) => Part::High, + }; + out.touched = true; + } + st.drag = Some(Drag { strip, grab, start, at: v, from: v, last_x: p.x }); + } + if response.dragged() + && let (Some(mut d), Some(p)) = (st.drag, response.interact_pointer_pos()) + { + let axis = if d.strip { span } else { view }; + let scale = (axis[1] - axis[0]) / f64::from(inner.width().max(1.0)); + d.at += f64::from(p.x - d.last_x) * scale * if mods.alt { FINE } else { 1.0 }; + d.last_x = p.x; + let mut snapped = None; + let mut put = |v: f64| if d.strip { v } else { snap(v.clamp(span[0], span[1]), mods.alt, &mut snapped) }; + let [a, b] = d.start; + let moved = match d.grab { + Grab::Band => { + let w = b - a; + let mut shift = d.at - d.from; + if !d.strip { + // The band snaps by whichever end lands on a change. + let (la, lb) = (put(a + shift) - (a + shift), put(b + shift) - (b + shift)); + shift += if la.abs() > 0.0 && (lb == 0.0 || la.abs() <= lb.abs()) { la } else { lb }; + } + let lo = (a + shift).clamp(span[0], span[1] - w); + [lo, lo + w] + } + Grab::New(anchor) => { + let v = put(d.at); + [anchor.min(v), anchor.max(v)] + } + Grab::Low | Grab::High => { + let v = put(d.at); + let other = if d.grab == Grab::Low { b } else { a }; + // An end dragged past the other becomes the other. + let low = v < other; + if !d.strip { + st.last = if low { Part::Low } else { Part::High }; + } + d.grab = if low { Grab::Low } else { Grab::High }; + d.start = if low { [a, other] } else { [other, b] }; + [v.min(other), v.max(other)] + } + }; + st.snapped = snapped; + if d.strip { + view = fit(moved, span); + st.loupe = Some(view); + } else { + range = moved; + out.touched = true; + } + st.drag = Some(d); + } + if response.drag_stopped() { + st.drag = None; + st.snapped = None; + } + if response.clicked() + && let Some(p) = response.interact_pointer_pos() + { + response.request_focus(); + let strip = on_strip(p); + let v = v_of(axis_of(strip, view), p.x); + if strip && grab_at(true, p.x, view, range).is_none() { + // A click beside the loupe brings it there. + let w = view[1] - view[0]; + view = fit([v - w / 2.0, v + w / 2.0], span); + st.loupe = Some(view); + } else if !strip && mods.shift && grab_at(false, p.x, view, range).is_none() { + let v = snap(v, mods.alt, &mut st.snapped); + let low = (v - range[0]).abs() < (v - range[1]).abs(); + range = if low { [v.min(range[1]), range[1]] } else { [range[0], v.max(range[0])] }; + st.last = if low { Part::Low } else { Part::High }; + out.touched = true; + } + } + if response.double_clicked() + && let Some(p) = response.interact_pointer_pos() + { + if on_strip(p) { + view = span; + st.loupe = None; + } else if grab_at(false, p.x, view, range) == Some(Grab::Band) { + let m = (range[1] - range[0]) * 0.08; + view = fit([range[0] - m, range[1] + m], span); + st.loupe = Some(view); + } else { + out.whole = true; + out.touched = true; + } + } + // The keyboard, while the track has it. + if response.has_focus() { + ui.memory_mut(|m| m.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, ..Default::default() })); + let (left, right, space) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) || i.consume_key(egui::Modifiers::ALT, egui::Key::ArrowLeft), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) || i.consume_key(egui::Modifiers::ALT, egui::Key::ArrowRight), + i.consume_key(egui::Modifiers::NONE, egui::Key::Space), + ) + }); + out.play = space; + if left || right { + let dir = if right { 1.0 } else { -1.0 }; + let step = |v: f64| { + let n = if mods.alt { None } else { next_change(track.changes, v, dir) }; + let v = n.unwrap_or(v + dir * per_point); + let v = if track.integer && n.is_none() { (v + dir * 0.5).round() } else { v }; + v.clamp(span[0], span[1]) + }; + range = match st.last { + Part::Low => [step(range[0]).min(range[1]), range[1]], + Part::High => [range[0], step(range[1]).max(range[0])], + Part::Band => { + let lead = if right { range[1] } else { range[0] }; + let w = range[1] - range[0]; + let lo = (range[0] + step(lead) - lead).clamp(span[0], span[1] - w); + [lo, lo + w] + } + }; + let moving = match st.last { + Part::Low => range[0], + Part::High => range[1], + Part::Band => { + if right { + range[1] + } else { + range[0] + } + } + }; + if moving < view[0] || moving > view[1] { + let w = view[1] - view[0]; + view = fit([moving - w / 2.0, moving + w / 2.0], span); + st.loupe = Some(view); + } + out.touched = true; + } + } + + // The pointer says what a press would take: an end, the band, or a new range beside them. + if let Some(p) = response.hover_pos().or_else(|| st.drag.and_then(|_| response.interact_pointer_pos())) { + let icon = match st.drag.map(|d| d.grab) { + Some(Grab::Band) => egui::CursorIcon::Grabbing, + Some(_) => egui::CursorIcon::ResizeHorizontal, + None => match grab_at(on_strip(p), p.x, view, range) { + Some(Grab::Band) => egui::CursorIcon::Grab, + Some(_) => egui::CursorIcon::ResizeHorizontal, + None if on_strip(p) => egui::CursorIcon::PointingHand, + None => egui::CursorIcon::Crosshair, + }, + }; + ctx.set_cursor_icon(icon); + } + + // The strip: the whole span, the range, and the loupe framed. + let painter = ui.painter_at(rect); + let empty = Bars::default(); + paint_bars(&painter, strip, span, track.overview.unwrap_or(&empty), t.control_fill, t.text_dim); + let band = |r: Rect, axis: [f64; 2], [a, b]: [f64; 2]| { + Rect::from_min_max(Pos2::new(x_of(axis, a).max(r.left()), r.top()), Pos2::new(x_of(axis, b).min(r.right()), r.bottom())) + }; + painter.rect_filled(band(strip, span, range), 0.0, t.form_primary.gamma_multiply(0.35)); + let frame = band(strip, span, view); + let shade = t.panel.gamma_multiply(0.7); + painter.rect_filled(Rect::from_min_max(strip.min, Pos2::new(frame.left(), strip.bottom())), 0.0, shade); + painter.rect_filled(Rect::from_min_max(Pos2::new(frame.right(), strip.top()), strip.max), 0.0, shade); + painter.rect_stroke(frame, 1.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + + // The ruler: steps of 1, 2 or 5 times a power of ten, at least 60 points apart. + let step = nice_step(60.0 * per_point); + let decimals = (-step.log10().floor()).clamp(0.0, 9.0) as usize; + let font = egui::FontId::proportional(t.font_micro); + let mut v = (view[0] / step).ceil() * step; + for _ in 0..64 { + if v > view[1] { + break; + } + let x = x_of(view, v); + painter.line_segment([Pos2::new(x, ruler.bottom() - 5.0), Pos2::new(x, ruler.bottom())], Stroke::new(1.0, t.border)); + painter.text(Pos2::new(x + 2.0, ruler.top()), egui::Align2::LEFT_TOP, format!("{v:.decimals$}"), font.clone(), t.text_dim); + v += step; + } + for c in lone_changes(track.changes, view, reach, 512) { + let x = x_of(view, c); + painter.line_segment([Pos2::new(x, ruler.bottom() - 3.0), Pos2::new(x, ruler.bottom() + 1.0)], Stroke::new(1.0, t.warning)); + } + + // The bars, what lies outside the range shaded, and the range's ends. + paint_bars(&painter, bars, view, track.loupe.unwrap_or(&empty), t.control_fill, t.text_dim); + let r = band(bars, view, range); + painter.rect_filled(Rect::from_min_max(bars.min, Pos2::new(r.left(), bars.bottom())), 0.0, shade); + painter.rect_filled(Rect::from_min_max(Pos2::new(r.right(), bars.top()), bars.max), 0.0, shade); + painter.rect_filled(r, 0.0, t.form_primary.gamma_multiply(0.18)); + for (v, part) in [(range[0], Part::Low), (range[1], Part::High)] { + if v < view[0] || v > view[1] { + continue; + } + let x = x_of(view, v); + let width = if response.has_focus() && st.last == part { 3.0 } else { 2.0 }; + painter.line_segment([Pos2::new(x, bars.top()), Pos2::new(x, bars.bottom())], Stroke::new(width, t.form_primary)); + painter.add(egui::Shape::convex_polygon( + vec![Pos2::new(x - 5.0, bars.top()), Pos2::new(x + 5.0, bars.top()), Pos2::new(x, bars.top() + 6.0)], + t.form_primary, + Stroke::NONE, + )); + } + if let (Some(s), Some(_)) = (st.snapped, st.drag) { + let x = x_of(view, s); + painter.line_segment([Pos2::new(x, bars.top()), Pos2::new(x, bars.bottom())], Stroke::new(1.0, t.warning)); + } + if response.has_focus() && st.last == Part::Band { + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.form_primary), egui::StrokeKind::Inside); + } + + // The ends and the length as numbers. + let decimals = if track.integer { 0 } else { (-(per_point.max(1e-12)).log10()).ceil().clamp(0.0, 6.0) as usize }; + let speed = if track.integer { 1.0 } else { per_point }; + ui.horizontal(|ui| { + let field = |ui: &mut Ui, v: &mut f64| ui_kit::track_number(ui, v, speed, decimals).changed(); + let (mut lo, mut hi) = (range[0], range[1]); + let mut length = hi - lo; + let changed_lo = field(ui, &mut lo); + ui.label("…"); + let changed_hi = field(ui, &mut hi); + ui.weak("↔"); + let changed_length = field(ui, &mut length); + let round = |v: f64| if track.integer { v.round() } else { v }; + if changed_lo { + range = [round(lo).min(range[1]), range[1]]; + st.last = Part::Low; + } else if changed_hi { + range = [range[0], round(hi).max(range[0])]; + st.last = Part::High; + } else if changed_length { + range = [range[0], range[0] + round(length).max(0.0)]; + st.last = Part::High; + } + if changed_lo || changed_hi || changed_length { + out.touched = true; + } + }); + + ctx.data_mut(|d| d.insert_temp(id(track.key), st)); + out.range = (range != track.range).then_some(range); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_change_snaps_only_when_it_stands_alone_at_this_zoom() { + let changes = [0.0, 10.0, 11.0, 30.0]; + assert_eq!(lone_change(&changes, 29.0, 2.0), Some(30.0)); + assert_eq!(lone_change(&changes, 10.4, 2.0), None, "10 and 11 are too close to tell apart"); + assert_eq!(lone_change(&changes, 10.4, 0.45), Some(10.0)); + assert_eq!(lone_changes(&changes, [0.0, 40.0], 2.0, 10), vec![0.0, 30.0]); + assert_eq!( + (next_change(&changes, 10.0, 1.0), next_change(&changes, 10.0, -1.0), next_change(&changes, 30.0, 1.0)), + (Some(11.0), Some(0.0), None) + ); + assert_eq!(nice_step(3.2), 5.0); + assert_eq!(nice_step(0.07), 0.1); + } +} diff --git a/crates/ui-egui/src/browser_cache.rs b/crates/ui-egui/src/browser_cache.rs index 50e2b96f4b..657343a183 100644 --- a/crates/ui-egui/src/browser_cache.rs +++ b/crates/ui-egui/src/browser_cache.rs @@ -25,24 +25,63 @@ pub(super) struct Node { pub folders: [Option; 3], } +/// A row of a folder or group: an item, or a group with its own rows. +pub(super) enum Row { + Item(Box), + Group(Group), +} + +impl Row { + pub fn count(&self) -> usize { + match self { + Row::Item(_) => 1, + Row::Group(g) => g.count, + } + } +} + pub(super) struct Group { pub id: u64, pub key: String, pub label: String, - pub items: Vec, + /// How many items it holds, groups inside it included. + pub count: usize, + pub rows: Vec, +} + +fn collect<'a>(rows: &'a [Row], out: &mut Vec<&'a Entry>) { + for r in rows { + match r { + Row::Item(e) => out.push(e), + Row::Group(g) => collect(&g.rows, out), + } + } +} + +impl Group { + /// Everything in the group, groups inside it included, in display order. + pub fn entries(&self) -> Vec<&Entry> { + let mut all = Vec::new(); + collect(&self.rows, &mut all); + all + } } #[derive(Default)] pub(super) struct Folder { pub key: String, - pub groups: Vec, - pub loose: Vec, - pub loose_keys: Vec, + pub rows: Vec, + /// The top-level rows as drawn: the order a drop among them keeps. + pub top_order: Vec, + /// How many items the folder holds, groups included. + pub count: usize, } impl Folder { pub fn entries(&self) -> impl Iterator { - self.groups.iter().flat_map(|g| &g.items).chain(&self.loose) + let mut all = Vec::new(); + collect(&self.rows, &mut all); + all.into_iter() } } @@ -190,16 +229,15 @@ impl Cache { if all.is_empty() { return Folder::default(); } - let (groups, loose) = arrange(&doc, component, folder, all); - let loose_keys = loose.iter().map(|e| e.key.clone()).collect(); - let groups = groups - .into_iter() - .map(|(group, items)| { - let key = format!("{prefix}/g{}", group.id); - let label = format!("{} ({})", group.name, items.len()); - Group { id: group.id, key, label, items } + let rows = arrange(&doc, component, folder, &prefix, all); + let top_order = rows + .iter() + .map(|r| match r { + Row::Item(e) => solidcraft_engine::doc::BrowserEntry::item(&e.key), + Row::Group(g) => solidcraft_engine::doc::BrowserEntry::Group(g.id), }) .collect(); - Folder { key: format!("{prefix}/{folder}"), groups, loose, loose_keys } + let count = rows.iter().map(Row::count).sum(); + Folder { key: format!("{prefix}/{folder}"), rows, top_order, count } } } diff --git a/crates/ui-egui/src/browser_canvas.rs b/crates/ui-egui/src/browser_canvas.rs index 96e4337f8f..dc1c281146 100644 --- a/crates/ui-egui/src/browser_canvas.rs +++ b/crates/ui-egui/src/browser_canvas.rs @@ -1,12 +1,17 @@ //! The Edit Canvas / Calibrate card the Browser opens for a reference image: position, size, //! angle, opacity and flip (live, one undo step per opening), or two points on the image and -//! their true distance. +//! their true distance. Calibrate accepts clicks in the viewport on the image (#9108). +use egui::{Pos2, Stroke, vec2}; use serde_json::{Value, json}; +use solidcraft_engine::doc::canvas::Canvas; +use solidcraft_engine::geom::Vec2; use crate::SolveApp; use crate::browser::panel_pos; +use crate::theme::Tokens; use crate::ui_kit; +use crate::viewport::Proj; pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut m) = app.tree.canvas_panel.clone() else { return }; @@ -26,6 +31,11 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) { .actions(ok, cancel, None) .show(ctx, true, |ui| { if calibrate { + ui_kit::text( + ui, + crate::i18n::tr(if m.next == 0 { "browser.calibrate_click_a" } else { "browser.calibrate_click_b" }), + ui_kit::Text::Note("calibrate_hint"), + ); for (label, p) in [("Point A", &mut m.a), ("Point B", &mut m.b)] { ui_kit::row(ui, crate::i18n::tr_label(label), |ui| { let [x, y] = p; @@ -74,6 +84,89 @@ pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) { app.tree.canvas_panel = if closed || action == ui_kit::Action::Cancel { None } else { Some(m) }; } +/// A viewport click while Calibrate is open: set Point A or B on the image (#9108). +pub(crate) fn click(app: &mut SolveApp, proj: &Proj, screen: Pos2) -> bool { + let Some(mut panel) = app.tree.canvas_panel.clone() else { + return false; + }; + if !panel.calibrate { + return false; + } + let doc = &app.session.doc; + let Some(c) = doc.canvases.iter().find(|c| c.id == panel.canvas).cloned() else { + return false; + }; + let (vals, _) = doc.param_values(); + let Ok(plane) = doc.canvas_plane(&vals, &c) else { + return false; + }; + let (origin, direction) = proj.ray(screen); + let Some(world) = plane.intersect_ray(origin, direction) else { + return false; + }; + let local = plane.to_local(world); + if !on_image(&c, local) { + return false; + } + let point = [local.x, local.y]; + if panel.next == 0 { + panel.a = point; + panel.next = 1; + app.set_status(crate::i18n::tr("browser.calibrate_click_b"), false); + } else { + panel.b = point; + panel.next = 0; + app.set_status(crate::i18n::tr("browser.calibrate_click_a"), false); + } + app.tree.canvas_panel = Some(panel); + true +} + +/// Markers for the two calibration points on the image. +pub(crate) fn paint_marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + let Some(panel) = app.tree.canvas_panel.as_ref().filter(|p| p.calibrate) else { + return; + }; + let doc = &app.session.doc; + let Some(c) = doc.canvases.iter().find(|c| c.id == panel.canvas) else { + return; + }; + let (vals, _) = doc.param_values(); + let Ok(plane) = doc.canvas_plane(&vals, c) else { + return; + }; + let t = Tokens::get(); + for (i, point) in [panel.a, panel.b].into_iter().enumerate() { + let Some(screen) = proj.to_screen(plane.to_world(Vec2::new(point[0], point[1]))) else { + continue; + }; + let active = panel.next as usize == i; + let stroke = Stroke::new(if active { 2.0 } else { 1.5 }, if active { t.accent } else { t.sel_edge }); + painter.circle_stroke(screen, 7.0, stroke); + painter.circle_filled(screen, 2.5, if active { t.accent } else { t.sel_vertex }); + let label = if i == 0 { "A" } else { "B" }; + painter.text( + screen + vec2(10.0, -10.0), + egui::Align2::LEFT_BOTTOM, + label, + egui::FontId::proportional(t.font_body), + if active { t.accent } else { t.text }, + ); + } +} + +fn on_image(c: &Canvas, p: Vec2) -> bool { + let [bl, br, _, tl] = c.corners(); + let (u, v) = (br - bl, tl - bl); + let (uu, vv) = (u.dot(u), v.dot(v)); + if !(uu > 1e-18 && vv > 1e-18) { + return true; + } + let w = p - bl; + let (s, t) = (w.dot(u) / uu, w.dot(v) / vv); + (-0.05..=1.05).contains(&s) && (-0.05..=1.05).contains(&t) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -126,4 +219,37 @@ mod tests { assert!(h.app.tree.canvas_panel.is_none()); assert!((width(&h) - 40.0).abs() < 1e-6, "calibrating to half the distance halves the image: {}", width(&h)); } + + /// Viewport clicks on the image fill Point A then Point B (#9108). + #[test] + fn calibrate_picks_two_points_from_viewport_clicks() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let id = canvas(&mut h); + h.app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(&h.app, id, true)); + h.frames(6); + let rect = h.app.viewport.rect.expect("viewport"); + let proj = crate::viewport::projection(&h.app, rect); + let c = h.app.session.doc.canvases[0].clone(); + let (vals, _) = h.app.session.doc.param_values(); + let plane = h.app.session.doc.canvas_plane(&vals, &c).unwrap(); + let ax = c.center.x - 20.0; + let bx = c.center.x + 20.0; + let sa = proj.to_screen(plane.to_world(solidcraft_engine::geom::Vec2::new(ax, c.center.y))).expect("point A on screen"); + let sb = proj.to_screen(plane.to_world(solidcraft_engine::geom::Vec2::new(bx, c.center.y))).expect("point B on screen"); + assert!(super::click(&mut h.app, &proj, sa), "first click sets A"); + let panel = h.app.tree.canvas_panel.as_ref().unwrap(); + assert_eq!(panel.next, 1); + assert!((panel.a[0] - ax).abs() < 0.5, "{:?}", panel.a); + assert!(super::click(&mut h.app, &proj, sb), "second click sets B"); + let panel = h.app.tree.canvas_panel.as_ref().unwrap(); + assert_eq!(panel.next, 0); + assert!((panel.b[0] - bx).abs() < 0.5, "{:?}", panel.b); + let before = panel.a; + let miss = plane.to_world(solidcraft_engine::geom::Vec2::new(c.center.x + 500.0, c.center.y)); + if let Some(sm) = proj.to_screen(miss) { + assert!(!super::click(&mut h.app, &proj, sm)); + assert_eq!(h.app.tree.canvas_panel.as_ref().unwrap().a, before); + } + } } diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs index 6114065d6f..7a778c9223 100644 --- a/crates/ui-egui/src/browser_card_tests.rs +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -268,7 +268,7 @@ fn card_and_column_keep_body_eyes_menus_and_group_drops() { h.step(&json!({"drag":[{"handle":"row:Two"},{"handle":"row:Group (1)"}]})).unwrap(); h.settle(); let group = h.app.session.doc.browser_groups.iter().find(|g| g.id == group).unwrap(); - assert!(group.items.iter().any(|name| name == "Two"), "body drop into the folder failed in {mode:?}"); + assert!(group.has_item("Two"), "body drop into the folder failed in {mode:?}"); } } @@ -696,3 +696,133 @@ fn browser_occurrence_clicks_fill_align_components_input() { h.step(&json!({"click": {"handle": &handle}})).unwrap(); assert!(h.app.dialog.as_ref().unwrap().inputs[0].items.is_empty()); } + +/// #8718: the eyes form one column at the rows' right end, right of each name and radio at every +/// depth, and a click on one hides without activating; the preference puts them back by the fold arrow. +#[test] +fn row_eyes_line_up_at_the_right_end_unless_the_preference_keeps_them_left() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Top"})).unwrap(); + let part = h.app.run("component.create", json!({"name":"Part"})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"Inner"})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + h.settle(); + let placement = format!("occurrence:{part}"); + let eye = |name: &str| handle_at(&format!("eye:{name}")).unwrap_or_else(|| panic!("eye:{name}")); + let label = |name: &str| rect_of(&format!("row_label:{name}")).unwrap_or_else(|| panic!("row_label:{name}")); + let activate = || handle_at(&format!("activate:{placement}")).unwrap(); + let card = rect_of("browser:card").unwrap(); + let column = eye(&placement).x; + for name in ["Bodies", "Top", "Inner", placement.as_str()] { + assert!((eye(name).x - column).abs() < 0.5, "{name}: {} is off the column at {column}", eye(name).x); + } + for name in ["Bodies", "Top", "Inner"] { + assert!(eye(name).x > label(name).right(), "{name}: the eye is right of the name"); + } + assert!(column > activate().x + 40.0 && column > card.right() - 40.0, "the eye sits at the row's end, apart from the radio"); + assert!(eye("Inner").y > eye(&placement).y + 10.0, "the body sits under its placement"); + let indent = |name: &str| handle_at(&format!("indent:{name}")).unwrap_or_else(|| panic!("indent:{name}")).x; + assert!((indent("Inner") - indent("Part:1") - super::INDENT).abs() < 0.5, "one level deeper, one indent further right"); + assert!((label("Inner").left() - label("Top").left()).abs() < 0.5, "bodies at one depth start their names together"); + // Its tip shows by the eye, not under the name. + h.step(&json!({"move":{"handle":"eye:Top"}})).unwrap(); + h.frames(60); + h.frame(); + let mut tip = None; + for clipped in &h.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |s| { + if let egui::Shape::Text(t) = s + && t.galley.text() == "Hide" + { + tip = Some(t.pos.x + t.galley.rect.center().x); + } + }); + } + let tip = tip.expect("the eye's Hide tip"); + assert!(tip > label("Top").right(), "the tip at {tip} is by the eye at {}", eye("Top").x); + + // Between the fold arrow and the radio, a click neither folds nor activates. + let (fold, radio) = (handle_at(&format!("fold:{placement}")).unwrap(), activate()); + assert!(radio.x - fold.x >= 24.0, "the radio keeps clear of the fold arrow: {fold:?} {radio:?}"); + for dx in [11.0, 14.0] { + h.frames(40); + h.step(&json!({"click":[fold.x + dx, fold.y]})).unwrap(); + h.settle(); + assert!(handle_at("indent:Inner").is_some(), "{dx}: the placement stays open"); + assert_eq!(h.app.session.active_component, 0, "{dx}: nothing is activated"); + } + h.frames(40); + h.step(&json!({"click":{"handle":format!("eye:{placement}")}})).unwrap(); + assert!(h.app.session.occurrence_hidden(&[part]), "the placement's eye hides it"); + h.step(&json!({"click":{"handle":"eye:Top"}})).unwrap(); + assert!(h.app.session.visibility.hidden_bodies.iter().any(|b| b == "Top"), "the body's eye hides it"); + assert_eq!(h.app.session.active_component, 0, "an eye click activates nothing"); + assert!(h.app.session.active_occurrence_path.is_none()); + + h.call("ui.prefs", json!({"browser_eye_left": true})); + h.settle(); + assert!(eye("Top").x < label("Top").left() && eye(&placement).x < activate().x, "beside the fold arrow, as before"); + assert!((eye("Inner").x - eye(&placement).x - super::INDENT).abs() < 0.5, "one level deeper, one indent further right"); + h.step(&json!({"click":{"handle":"eye:Top"}})).unwrap(); + assert!(!h.app.session.visibility.hidden_bodies.iter().any(|b| b == "Top"), "the eye still shows it"); + assert_eq!(h.app.session.active_component, 0); +} + +/// #8718: with the rows overflowing the card, an eye at the rows' end stays clear of the scroll +/// bar laid over them: a click on it hides the sketch and scrolls nothing. +#[test] +fn an_eye_at_the_row_end_takes_its_click_from_the_scroll_bar() { + 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:Sketch3").unwrap(); + let row = handle_at("row:Sketch3").unwrap(); + let bar = h.ctx.global_style().spacing.scroll.bar_width; + let row_rect = drawn_rows().into_iter().find(|r| r.0 == "Sketch3").unwrap().1; + assert!(card.contains_rect(row_rect)); + let half = (Tokens::get().space_16 + Tokens::get().space_3) / 2.0; + assert!(eye.x + half <= row_rect.right() - bar, "the eye at {} is left of the bar along {}", eye.x, row_rect.right()); + // The bar widens under the pointer before the click. + h.step(&json!({"move":[row_rect.right() - 3.0, eye.y]})).unwrap(); + h.frames(30); + // The eye's edge nearest the bar. + h.step(&json!({"click":[eye.x + half - 1.0, eye.y]})).unwrap(); + h.settle(); + assert_eq!(h.app.session.visibility.hidden_sketches.len(), 1, "the eye hides its sketch"); + assert_eq!(handle_at("row:Sketch3"), Some(row), "the list did not scroll"); +} + +/// Dragging a group's row onto the middle of another group's row nests it; onto the top of a row +/// puts it before that group. A group cannot be dropped into one of its own groups. +#[test] +fn a_group_dragged_onto_another_group_nests_in_it_or_goes_before_it() { + let mut h = Harness::new(); + let ids: Vec = ["Alpha", "Beta", "Gamma"] + .into_iter() + .enumerate() + .map(|(i, name)| { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[i as f64 * 20.0,0,0],"body_name":name})).unwrap(); + h.app.run("browser.group", json!({"component":0,"folder":"bodies","items":[name],"name":name})).unwrap()["group"].as_u64().unwrap() + }) + .collect(); + let (alpha, beta, gamma) = (ids[0], ids[1], ids[2]); + h.settle(); + let drag = |h: &mut Harness, from: &str, to: &str, dy: f32| { + let (from, to) = (handle_at(from).unwrap(), handle_at(to).unwrap()); + assert_eq!(h.call("ui.drag", json!({"x0":from.x,"y0":from.y,"x1":to.x,"y1":to.y + dy,"steps":12}))["ok"], true); + h.settle(); + }; + let parent = |h: &Harness, id: u64| h.app.session.doc.browser_group(id).unwrap().parent; + drag(&mut h, "row:Alpha (1)", "row:Beta (1)", 0.0); + assert_eq!(parent(&h, alpha), Some(beta)); + drag(&mut h, "row:Beta (2)", "row:Alpha (1)", 0.0); + assert_eq!(parent(&h, beta), None, "a group does not go into its own group"); + 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)]); +} diff --git a/crates/ui-egui/src/browser_model.rs b/crates/ui-egui/src/browser_model.rs index 1def459676..9b116b6dbd 100644 --- a/crates/ui-egui/src/browser_model.rs +++ b/crates/ui-egui/src/browser_model.rs @@ -38,6 +38,9 @@ impl Model { if !matches!(feature.kind, FeatureKind::Sketch { .. }) { features.entry(feature.component).or_default().push(i); } + if session.doc.marker.is_some_and(|marker| i >= marker) { + continue; + } let index = match feature.kind { FeatureKind::Sketch { .. } => &mut sketches, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. } => &mut planes, @@ -49,6 +52,10 @@ impl Model { for occurrence in &session.doc.occurrences { children.entry(occurrence.parent).or_default().push(occurrence.id); } + // Sub-components in the order the design saved for them; the rest follow in placement order. + for (parent, ids) in &mut children { + session.doc.sort_placements(*parent, ids); + } Self { doc: session.doc.clone(), state, diff --git a/crates/ui-egui/src/browser_model_tests.rs b/crates/ui-egui/src/browser_model_tests.rs index 9e69310237..9ebf21d6fa 100644 --- a/crates/ui-egui/src/browser_model_tests.rs +++ b/crates/ui-egui/src/browser_model_tests.rs @@ -410,6 +410,35 @@ fn browser_rows_still_fold_or_select_when_move_copy_is_not_open() { assert!(h.app.tree.collapsed.contains("c0/bodies")); } +#[test] +fn feature_editor_browser_rows_follow_the_marker_and_return_after_cancel() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("solid.extrude", json!({"sketch":"Base","distance":10,"name":"Depth"})).unwrap(); + let feature = h.app.session.doc.find_feature("Depth").unwrap().id; + h.app.run("sketch.create", json!({"plane":"YZ","name":"Later sketch"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("construct.plane.offset", json!({"base":"XY","offset":20,"name":"Later plane"})).unwrap(); + super::prepare_model(&mut h.app); + assert_eq!(super::entries(&h.app, 0, "sketches").len(), 2); + assert_eq!(super::entries(&h.app, 0, "construction").len(), 1); + h.app.edit_feature(feature); + // Both the fallback queries and the cached index must follow rollback. + assert_eq!(super::entries(&h.app, 0, "sketches").iter().map(|e| e.label.as_str()).collect::>(), ["Base"]); + assert!(super::entries(&h.app, 0, "construction").is_empty()); + assert!(super::entries(&h.app, 0, "bodies").is_empty()); + super::prepare_model(&mut h.app); + assert_eq!(super::entries(&h.app, 0, "sketches").len(), 1); + assert!(super::entries(&h.app, 0, "construction").is_empty()); + crate::dialogs::cancel(&mut h.app); + super::prepare_model(&mut h.app); + assert_eq!(super::entries(&h.app, 0, "sketches").len(), 2); + assert_eq!(super::entries(&h.app, 0, "construction").len(), 1); + assert_eq!(super::entries(&h.app, 0, "bodies").len(), 1); +} + /// The sketch being edited is marked in the user's language, not with an English "(editing)", /// and its default name reads as the browser shows it elsewhere. #[test] @@ -420,3 +449,45 @@ fn the_edited_sketch_is_marked_in_the_users_language() { let rows = super::entries(&h.app, 0, "sketches"); assert_eq!(rows.iter().map(|r| r.label.as_str()).collect::>(), ["スケッチ1 (編集中)"]); } + +/// A design saved before groups could nest or be ordered shows the browser it always did: the +/// groups in creation order, each with its items, above the loose items, which follow the saved +/// order and leave the ones it does not name to the end. That layout is spelled out here and +/// compared with the rows drawn now, including a saved order that still names a grouped item. +#[test] +fn a_design_saved_before_nested_groups_keeps_its_browser_order() { + use super::cache::Row; + const BODIES: [&str; 5] = ["Plate", "Washer", "Bolt", "Nut", "Cap"]; + fn drawn(rows: &[Row]) -> Vec { + rows.iter() + .map(|r| match r { + Row::Item(e) => e.key.clone(), + Row::Group(g) => format!("{{{}: {}}}", g.label, drawn(&g.rows).join(",")), + }) + .collect() + } + let cases: [(&[(&str, &[&str])], &[&str]); 3] = [ + (&[("Fasteners", &["Bolt", "Nut"])], &[]), + (&[("Fasteners", &["Bolt", "Nut"])], &["Washer", "Bolt", "Cap"]), + (&[("Zeta", &["Plate"]), ("Alpha", &["Cap", "Washer"])], &["Cap", "Nut"]), + ]; + for (groups, saved) in cases { + let mut h = crate::scenario::Harness::new(); + for (i, name) in BODIES.iter().enumerate() { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[i as f64 * 20.0, 0, 0],"body_name":name})).unwrap(); + } + for (name, items) in groups { + h.app.run("browser.group", json!({"component":0,"folder":"bodies","name":name,"items":items})).unwrap(); + } + if !saved.is_empty() { + h.app.run("browser.order", json!({"folder":"bodies","order":saved})).unwrap(); + } + // The old layout: groups in creation order, then the loose items by their saved place. + let mut expected: Vec = groups.iter().map(|(n, items)| format!("{{{n} ({}): {}}}", items.len(), items.join(","))).collect(); + let mut loose: Vec<&str> = BODIES.into_iter().filter(|b| !groups.iter().any(|(_, items)| items.contains(b))).collect(); + loose.sort_by_key(|b| saved.iter().position(|s| s == b).unwrap_or(usize::MAX)); + expected.extend(loose.into_iter().map(String::from)); + let rows = super::arrange(&h.app.session.doc, 0, "bodies", "c0", super::entries(&h.app, 0, "bodies")); + assert_eq!(drawn(&rows), expected, "groups {groups:?}, saved order {saved:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f6ac713955..8a7c928596 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -349,7 +349,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj crate::dialogs_move::step_value_field(&mut d, if back { -1 } else { 1 }); d.focus = true; } - let focus = std::mem::take(&mut d.focus); + let focus = take_focus(ui.ctx(), &mut d); if let Some(bs) = at.and_then(|base| proj.to_screen(base)) && let Some((label, _, value)) = d.primary() { @@ -541,7 +541,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { mode, .. }) => !crate::sketch_modify_ui::offset_symmetric(*mode), _ => false, }; - let mut focus = std::mem::take(&mut d.focus); + let mut focus = take_focus(ui.ctx(), d); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); if matches!(d.kind, Kind::Revolve { .. }) { @@ -785,6 +785,28 @@ pub(crate) fn box_place(app: &SolveApp, proj: &Proj) -> BoxPlace { BoxPlace { dialog: app.viewport.dialog_rect, view: Some(proj.rect), handles: None } } +fn boxes_shown_id() -> egui::Id { + egui::Id::new("sc_canvas_value_boxes") +} + +/// Show commands' values in boxes in the view or not, from the preference (the app calls this +/// every frame). +pub(crate) fn set_boxes_shown(ctx: &egui::Context, on: bool) { + ctx.data_mut(|d| d.insert_temp(boxes_shown_id(), on)); +} + +/// Whether a command's values have boxes in the view. Off (Preferences), they are typed in the +/// command window only, and the view stays clear for picking (#9479). +pub(crate) fn boxes_shown(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(boxes_shown_id())).unwrap_or(true) +} + +/// Whether the command's value box takes the keyboard this frame: as the window opens, and after +/// a pick. Without boxes in the view, it is left for the command window's value field (#9479). +fn take_focus(ctx: &egui::Context, d: &mut Dialog) -> bool { + boxes_shown(ctx) && std::mem::take(&mut d.focus) +} + /// The command's main value in the viewport, next to the geometry: the keyboard goes here while /// the window is open, so a number typed straight after a pick sets the value. `at` is its /// top-left corner. `select_all` re-selects the whole field, both right after a pick and on every @@ -838,6 +860,10 @@ fn value_box_full( note: Option<&str>, place: BoxPlace, ) -> Option { + // Off in Preferences: the value is typed in the command window only (#9479). + if !boxes_shown(ui.ctx()) { + return None; + } let t = Tokens::get(); // Held in place while the pointer is over it and value stepping is on (see `value_box`): by // its pivot, the point of the box that sits at `at`, which is not always its top-left corner. diff --git a/crates/ui-egui/src/command_help.rs b/crates/ui-egui/src/command_help.rs index d34ce0621e..aac64337bd 100644 --- a/crates/ui-egui/src/command_help.rs +++ b/crates/ui-egui/src/command_help.rs @@ -15,7 +15,18 @@ pub const DOCUMENTED: &[&str] = &[ "occurrence.move", "parameters.change", "pointcloud.insert", + "sketch.construction_line", + "sketch.construction_line.angle", + "sketch.construction_line.bisector", + "sketch.construction_line.horizontal", + "sketch.construction_line.parallel", + "sketch.construction_line.perpendicular", + "sketch.construction_line.tangent_at", + "sketch.construction_line.tangent_external", + "sketch.construction_line.tangent_two", + "sketch.construction_line.vertical", "sketch.equation_curve", + "sketch.inspect", "solid.align", "solid.chamfer", "solid.combine", diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 84842b57d9..643f061eb0 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -65,6 +65,10 @@ pub enum Target { Units, /// Insert › Insert Design: the designs open in other tabs, and Browse… (#8570). InsertDesign, + /// View › Visual style: the four styles, the current one marked (#8868). + VisualStyle, + /// View › Navigation tool: Orbit, Pan, Zoom; the latched one marked (#8868). + ViewTool, /// The mouse mappings (Preferences › Navigation), from the status bar (#7023). Navigation, /// An assembly joint. @@ -431,6 +435,38 @@ 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::VisualStyle => crate::navigation_bar::VISUAL_STYLES + .iter() + .enumerate() + .map(|(index, (style, label))| { + let current = usize::from(app.ui.visual_style) == index; + let mut item = cmd(app, "view.visual_style", label).with(json!({"style": style})).key(if current { "current" } else { "" }); + item.icon = crate::navigation_bar::visual_style_icon(u8::try_from(index).unwrap_or_default()).into(); + item + }) + .collect(), + Target::ViewTool => { + let latched = match app.viewport.nav { + Some(crate::viewport::NavMode::Orbit) => "orbit", + Some(crate::viewport::NavMode::Pan) => "pan", + Some(crate::viewport::NavMode::Zoom) => "zoom", + None => "", + }; + [("orbit", "Orbit"), ("pan", "Pan"), ("zoom", "Zoom")] + .into_iter() + .map(|(tool, label)| { + let current = latched == tool; + // Picking the latched tool again releases it, as its navigation-bar button does. + let mut item = cmd(app, "view.tool", label).with(json!({"tool": if current { "none" } else { tool }})).key(if current { + "current" + } else { + "" + }); + item.icon = tool.into(); + item + }) + .collect() + } }; if !crate::render_workspace::active(app) { v.extend(crate::browser::folded_folder_items(app, target)); @@ -631,6 +667,9 @@ fn viewport_items(app: &SolveApp) -> Vec { let planar = sel.iter().any(|s| matches!(s, Sel::Face { body, index, .. } if crate::dialogs::planar_face(&app.session, body, *index).is_some())); v.push(act("ui.lookAt", "Look At", "lookat").on(planar)); + let marked = matches!(sel.as_slice(), [Sel::Face { body, index, .. }] if crate::print_face_ui::is_print_face(app, body, *index)); + let one_flat = planar && matches!(sel.as_slice(), [Sel::Face { .. }]); + v.push(cmd(app, "body.print_face", if marked { "Clear Print Face" } else { "Print Face" }).on(one_flat)); v.push(Item::sep()); v.push(act("ui.appearance", "Appearance", "appearance").key("A")); v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] }))); @@ -1721,7 +1760,11 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } "ui.editCanvas" | "ui.calibrateCanvas" => { if let Some(c) = id_of(&p, "canvas") { - app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, item.id == "ui.calibrateCanvas")); + let calibrate = item.id == "ui.calibrateCanvas"; + app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, calibrate)); + if calibrate { + app.set_status(crate::i18n::tr("browser.calibrate_click_a"), false); + } } } "ui.newGroup" | "ui.ungroup" | "ui.groupSelected" => crate::browser::group_action(app, &item.id, &p), @@ -2583,6 +2626,67 @@ mod tests { assert!(h.app.viewport.context_menu.is_none()); } + #[test] + fn visual_style_toolbar_start_offers_the_four_styles_not_an_error() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let error = |h: &crate::scenario::Harness| h.app.status.as_ref().is_some_and(|(_, _, error)| *error); + h.app.status = None; + h.call("ui.start", json!({"command":"view.visual_style"})); + assert!(!error(&h), "{:?}", h.app.status); + assert_eq!(h.app.menu.target, Target::VisualStyle); + assert!(h.app.viewport.context_menu.is_some()); + let menu = describe(&h.app); + let labels: Vec<_> = menu["items"].as_array().unwrap().iter().map(|i| i["label"].as_str().unwrap().to_owned()).collect(); + assert_eq!(labels, ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]); + let rows = items(&h.app, &Target::VisualStyle); + assert_eq!(rows.iter().filter(|i| i.shortcut == "current").map(|i| i.label.as_str()).collect::>(), ["Shaded with edges"]); + h.call("ui.menuPick", json!({"item":"Wireframe"})); + assert_eq!(h.app.ui.visual_style, 2); + assert!(h.app.viewport.context_menu.is_none()); + assert!(!error(&h)); + assert!(h.app.session.undo.is_empty(), "a display style is not a design edit"); + // The real ribbon path: the VIEW tab's panel drop-down, then the Visual style row. + h.step(&json!({"click":{"handle":"tab:VIEW"}})).unwrap(); + h.step(&json!({"click":{"handle":"panel:VIEW:VIEWS"}})).unwrap(); + h.step(&json!({"click":{"handle":"toolbar:view.visual_style"}})).unwrap(); + assert!(!error(&h), "{:?}", h.app.status); + assert_eq!(h.app.menu.target, Target::VisualStyle); + assert!(h.app.viewport.context_menu.is_some()); + assert_eq!(items(&h.app, &Target::VisualStyle).iter().find(|i| i.label == "Wireframe").unwrap().shortcut, "current"); + // The drop-down closes on a pick, so the toggle is checked with the opener's rect directly. + let button = egui::Rect::from_min_size(egui::pos2(40.0, 60.0), egui::vec2(28.0, 28.0)); + h.app.start_at("view.visual_style", Some(button)); + assert_eq!(h.app.viewport.context_menu, Some(button.left_bottom())); + h.app.start_at("view.visual_style", Some(button)); + assert!(h.app.viewport.context_menu.is_none(), "a second click closes the menu"); + let missing = h.call("engine.execute", json!({"command":"view.visual_style","params":{}})); + assert_eq!(missing["ok"], false); + assert!(h.app.viewport.context_menu.is_none()); + } + + #[test] + fn view_tool_toolbar_start_offers_orbit_pan_zoom_and_releases_the_latched_one() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.status = None; + let error = |h: &crate::scenario::Harness| h.app.status.as_ref().is_some_and(|(_, _, error)| *error); + h.call("ui.start", json!({"command":"view.tool"})); + assert!(!error(&h), "{:?}", h.app.status); + assert_eq!(h.app.menu.target, Target::ViewTool); + let menu = describe(&h.app); + let labels: Vec<_> = menu["items"].as_array().unwrap().iter().map(|i| i["label"].as_str().unwrap().to_owned()).collect(); + assert_eq!(labels, ["Orbit", "Pan", "Zoom"]); + assert!(items(&h.app, &Target::ViewTool).iter().all(|i| i.shortcut != "current")); + h.call("ui.menuPick", json!({"item":"Pan"})); + assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan)); + h.call("ui.start", json!({"command":"view.tool"})); + assert_eq!(items(&h.app, &Target::ViewTool).iter().find(|i| i.label == "Pan").unwrap().shortcut, "current"); + h.call("ui.menuPick", json!({"item":"Pan"})); + assert_eq!(h.app.viewport.nav, None, "picking the latched tool releases it, as its navigation-bar button does"); + assert!(!error(&h)); + } + #[test] fn line_offset_context_menu_breaks_association_and_allows_trim() { use solidcraft_engine::{geom::Vec2, sketch::Shape}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b97d075bed..17c43ccbab 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -32,7 +32,7 @@ //! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do //! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists) //! - `ui.shortcut {command, key?, slot?, replace?}`: a command's keys, or bind one ("" clears) -//! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics +//! - `ui.help {item?: about|updates|shortcuts|report}`: the Help menu; returns the version and diagnostics //! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_around, msaa, //! length_decimals, angle_decimals); returns them all //! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question @@ -49,6 +49,7 @@ use std::time::Duration; use serde_json::{Value, json}; use solidcraft_engine::io::vfs; use solidcraft_engine::render::{Camera, StandardView}; +use solidcraft_engine::schema::ParamCheck; use solidcraft_engine::{batch_params, timing}; use crate::SolveApp; @@ -434,7 +435,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides /// The grid and the origin's axes as the window shows them (the Grid and Origin switches). pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { solidcraft_engine::view::Guides { - grid: app.session.active_sketch.is_some() || (app.ui.show_grid && !crate::viewport::active_view(app).hide_grid), + grid: (app.session.active_sketch.is_some() || app.ui.show_grid) && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible(), } } @@ -452,17 +453,84 @@ pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result, String> { } pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { - let scope = match request_scope(app, &req.params) { - Ok(Some(scope)) => scope, - Ok(None) => return dispatch(app, ctx, &req.method, &req.params), + let level = match request_param_check(&req.method, &req.params) { + Ok(level) => level, Err(e) => return err(e), }; + let scope = match request_scope(app, &req.params) { + Ok(scope) => scope, + Err(e) => return err(e), + }; + if scope.is_none() && level.is_none() { + return dispatch(app, ctx, &req.method, &req.params); + } let mut p = req.params.clone(); if let Some(o) = p.as_object_mut() { o.remove("allow_paths"); o.remove("design_folders"); + o.remove("param_check"); } - vfs::scoped(Some(scope), || dispatch(app, ctx, &req.method, &p)) + let run = |app: &mut SolveApp| match level { + Some(level) => dispatch_checked(app, ctx, &req.method, &p, level), + None => dispatch(app, ctx, &req.method, &p), + }; + match scope { + Some(scope) => vfs::scoped(Some(scope), || run(app)), + None => run(app), + } +} + +/// The request's `param_check` (#9184): how the commands of an `engine.execute`, `engine.batch` +/// or `engine.script` are checked against their contracts, whatever the app's own session level; +/// `None` when it names none (other methods ignore the field). +pub(crate) fn request_param_check(method: &str, params: &Value) -> Result, String> { + if !matches!(method, "engine.execute" | "command" | "engine.batch" | "engine.script") { + return Ok(None); + } + match params.get("param_check") { + None => Ok(None), + Some(v) => v.as_str().and_then(ParamCheck::parse).map(Some).ok_or_else(|| "`param_check` must be off, warn or strict".to_string()), + } +} + +/// `dispatch` of an `engine.execute`, `engine.batch` or `engine.script` with its commands checked +/// at `level`, echoed in the reply as `param_check`, so a client can tell an app that checked +/// from an older one that ignored the field. The session goes back to its own level afterwards. +fn dispatch_checked(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value, level: ParamCheck) -> Outcome { + let mut out = if switches_design(p).is_some() { + switch_checked(app, ctx, method, p, level) + } else { + let own = app.session.param_check; + app.session.param_check = level; + let out = dispatch(app, ctx, method, p); + app.session.param_check = own; + out + }; + if let Outcome::Done(Value::Object(o)) = &mut out { + o.insert("param_check".into(), json!(level.name())); + } + out +} + +/// A command that switches to another design, checked at `level` before it runs: it runs at the +/// app's own level, since the session it leaves and the one it brings in each keep theirs. +fn switch_checked(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value, level: ParamCheck) -> Outcome { + let id = command(p).unwrap_or_default(); + let issues: Vec = solidcraft_engine::schema::issues(id, p.get("params").unwrap_or(&Value::Null)).into_iter().map(|i| i.message).collect(); + if level == ParamCheck::Strict + && let Some(first) = issues.first() + { + return err(solidcraft_engine::params::bad(id, first.as_str())); + } + let mut out = dispatch(app, ctx, method, p); + if level == ParamCheck::Warn + && !issues.is_empty() + && let Outcome::Done(v) = &mut out + && let Some(o) = v.get_mut("result").and_then(Value::as_object_mut) + { + o.insert("param_warnings".into(), json!(issues)); + } + out } fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome { @@ -585,8 +653,17 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> Some(false) => app.home.open = false, None => {} } - if let Some(i) = p.get("sample").and_then(Value::as_u64) { - crate::home::open_sample(app, i as usize); + if let Some(sample) = p.get("sample") { + // The index is checked here: `open_sample` drops an index that names no sample + // and says nothing, so the request would look like it had worked (#9087). + let count = solidcraft_engine::sample::samples().len(); + let Some(index) = sample.as_i64() else { + return err(format!("`sample` must be an index from 0 to {}", count - 1)); + }; + if !(0..count as i64).contains(&index) { + return err(format!("`sample` must be an index from 0 to {}, not {index}", count - 1)); + } + crate::home::open_sample(app, index as usize); } ok(json!({"open": app.home.open, "recent": app.home.recent})) } @@ -1054,7 +1131,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> "ui.prefs" => wrap(crate::prefs::control(app, p)), "ui.agent_chat" => wrap(crate::agent_chat::control(app, ctx, p)), "ui.help" => match s("item") { - Some(i @ ("about" | "shortcuts" | "report" | "crashes")) => { + Some(i @ ("about" | "updates" | "shortcuts" | "report" | "crashes")) => { crate::help::run(app, ctx, i); ok( json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}), @@ -1907,6 +1984,60 @@ mod tests { assert!(!v["result"]["failed"]["error"].as_str().is_some_and(|e| e.starts_with("unknown command")), "{v}"); } + /// A request's own `param_check` (#9184): `engine.execute`, `engine.batch` and `engine.script` + /// run at it and say so in the reply, whatever the app's session level, which is its own again + /// afterwards. A command that switches designs keeps the app's level. + #[test] + fn a_request_carries_its_own_param_check_level() { + use solidcraft_engine::schema::ParamCheck; + let mut h = crate::scenario::Harness::new(); + let before = inspect(&mut h); + let warning = "unknown key `operaton`; did you mean `operation`?"; + let misspelt = |level: &str| json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "operaton": "cut"}, "param_check": level}); + + let v = h.call("engine.execute", misspelt("strict")); + assert_eq!((&v["ok"], &v["param_check"]), (&json!(false), &json!("strict")), "{v}"); + assert!(v["error"].as_str().is_some_and(|e| e.contains(warning)), "{v}"); + assert_eq!(inspect(&mut h)["bodies"], before["bodies"], "refused unrun"); + assert_eq!(h.app.session.param_check, ParamCheck::Off, "the app's own level again"); + + let v = h.call("engine.execute", misspelt("warn")); + assert_eq!((&v["ok"], &v["param_check"], &v["result"]["param_warnings"]), (&json!(true), &json!("warn"), &json!([warning])), "{v}"); + assert_eq!(h.app.session.param_check, ParamCheck::Off); + + let steps = json!([ + {"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}}, + {"command": "solid.box", "params": {"lenght": 10, "width": 10, "height": 10}}, + ]); + let shown = inspect(&mut h)["bodies"].clone(); + let v = h.call("engine.batch", json!({"commands": steps, "param_check": "strict"})); + assert_eq!((&v["result"]["completed"], &v["result"]["failed"]["index"], &v["param_check"]), (&json!(0), &json!(1), &json!("strict")), "{v}"); + assert_eq!(inspect(&mut h)["bodies"], shown, "nothing ran"); + let step = json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "operaton": "cut"}}); + let v = h.call("engine.script", json!({"commands": [step], "param_check": "warn"})); + assert_eq!((&v["param_check"], &v["result"][0]["param_warnings"]), (&json!("warn"), &json!([warning])), "{v}"); + + // Asked nothing, told nothing; a word that is no level is refused. + let v = h.call("engine.execute", json!({"command": "inspect.measure", "params": {}})); + assert_eq!((&v["ok"], v.get("param_check")), (&json!(true), None), "{v}"); + let v = h.call("engine.execute", json!({"command": "inspect.measure", "params": {}, "param_check": "loud"})); + assert!(v["ok"] == false && v["error"].as_str().is_some_and(|e| e.contains("`param_check` must be off, warn or strict")), "{v}"); + // The level is this request's, not another design's. + let v = h.call("engine.execute", json!({"command": "view.next_tab", "params": {}, "param_check": "strict"})); + assert_eq!((&v["ok"], &v["param_check"]), (&json!(true), &json!("strict")), "{v}"); + assert_eq!(h.app.session.param_check, ParamCheck::Off); + // Its own parameters are still checked at the request's level, before it runs. + let v = h.call("engine.execute", json!({"command": "view.next_tab", "params": {"nonsense": 1}, "param_check": "strict"})); + assert!(v["ok"] == false && v["error"].as_str().is_some_and(|e| e.contains("nonsense")), "{v}"); + assert_eq!(v["param_check"], "strict", "{v}"); + let v = h.call("engine.execute", json!({"command": "view.next_tab", "params": {"nonsense": 1}, "param_check": "warn"})); + assert!(v["result"]["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{v}"); + assert_eq!(h.app.session.param_check, ParamCheck::Off); + // Other methods ignore the field, whatever it holds. + let v = h.call("ui.inspect", json!({"param_check": "loud"})); + assert_eq!((&v["ok"], v.get("param_check")), (&json!(true), None), "{v}"); + } + #[test] fn engine_batch_is_refused_when_a_step_loads_a_file() { let mut h = crate::scenario::Harness::new(); @@ -2276,4 +2407,23 @@ mod tests { assert!(h.app.dialog.is_none() && h.app.tool.is_none() && h.app.session.analysis.is_none(), "{command} started nothing"); } } + + /// #9087: a sample index that names no sample is refused, so an agent can tell a typo from + /// a sample that opened. The index is checked before it reaches `open_sample`, which would + /// otherwise drop it and reply `ok`. + #[test] + fn ui_home_refuses_a_sample_index_that_names_no_sample() { + let mut h = crate::scenario::Harness::new(); + let count = solidcraft_engine::sample::samples().len(); + for bad in [json!({"sample": count}), json!({"sample": 99}), json!({"sample": -1})] { + let r = h.call("ui.home", bad.clone()); + assert_eq!(r["ok"], false, "{bad}: {r}"); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains(&format!("0 to {}", count - 1)), "the error names the valid range: {bad}: {r}"); + } + // A sample that exists still opens. + let r = h.call("ui.home", json!({"sample": 0})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["open"], true, "the sample was asked for: {r}"); + } } diff --git a/crates/ui-egui/src/control_job.rs b/crates/ui-egui/src/control_job.rs index fe2c45ebaf..33829820fd 100644 --- a/crates/ui-egui/src/control_job.rs +++ b/crates/ui-egui/src/control_job.rs @@ -406,12 +406,22 @@ impl SolveApp { /// is taken care of; `Some`: it runs here (no threads). fn answer_on_a_copy(&mut self, req: ControlRequest, id: &'static str, params: Value) -> Option { let Ok(scope) = crate::control::request_scope(self, &req.params) else { return Some(req) }; + // The request's own level (#9184) checks it on the copy, and the reply says so, as + // `control::handle` does; a word that is no level is refused there. + let Ok(level) = crate::control::request_param_check(&req.method, &req.params) else { return Some(req) }; let mut copy = self.session.scratch(); + if let Some(level) = level { + copy.param_check = level; + } let rx = spawn(scope, move || { - Some(match copy.execute(id, ¶ms) { + let mut reply = match copy.execute(id, ¶ms) { Ok(v) => json!({"ok": true, "result": v}), Err(e) => json!({"ok": false, "error": e.to_string()}), - }) + }; + if let Some(level) = level { + reply["param_check"] = json!(level.name()); + } + Some(reply) }); let Some(rx) = rx else { return Some(req) }; let job = ControlJob { label: label(self, id), req, work: Work::Answer, done: rx, started: crate::now_ms(), steps: None }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9e4b4804c6..9bc2cdb49f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -464,6 +464,9 @@ pub struct Dialog { pub error: Option, /// Editing an existing feature (its id, and the timeline marker to restore afterwards). pub editing: Option<(u64, Option)>, + /// Hidden face-profile sources shown only for this editor's lifetime. Visibility itself is + /// unchanged, so OK, Cancel, failed apply and replacing the dialog cannot leak an override. + pub(crate) edit_source_bodies: Vec, /// A user pick or clear makes the profile selector explicit, even if all are reselected. pub profile_selection_changed: bool, /// Give the on-canvas value box the keyboard (and select its text) on the next frame. @@ -736,6 +739,7 @@ impl Dialog { active: 0, error: None, editing: None, + edit_source_bodies: Vec::new(), profile_selection_changed: false, focus: true, value_focus: None, @@ -1586,6 +1590,42 @@ impl Dialog { true } + /// Give the keyboard to the form's value field `id`, its text selected, once the pointer is + /// idle: where a pick gives it when values have no boxes in the view (#9479). + pub(crate) fn give_keyboard(&mut self, id: egui::Id) { + self.value_focus = Some(id); + self.restore_value_focus = true; + } + + /// A sketch clicked in the Browser or the History while Extrude, Revolve or Emboss is open + /// (#6106): Profiles takes every profile of the sketch, as when the sketch is selected before + /// 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.active != 0 + || !matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) + || !self.inputs.first().is_some_and(|i| i.accept & PROFILES != 0) + || !s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. })) + { + return false; + } + let profiles: Vec = s + .world_state() + .sketch(id) + .map(|ss| outermost_profiles(&ss.profiles).map(|index| Sel::Profile { sketch: id, index }).collect()) + .unwrap_or_default(); + let Some(input) = self.inputs.first_mut() else { return false }; + if profiles.is_empty() { + return false; + } + input.items = profiles; + self.profile_selection_changed = true; + self.focus = true; + self.error = None; + self.sync_thread(s); + 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) { @@ -1764,6 +1804,19 @@ fn fillet_edges(s: &Session, sel: &Sel, chain: bool) -> Vec { picked } +/// The profiles of a sketch that aren't the island inside another profile's hole: a circle in a +/// rectangle gives the rectangle-with-hole and the disc, and taking both would fill the hole. +/// Loops are compared as sets of curve ids, so where a loop starts or which way it runs doesn't matter. +fn outermost_profiles(profiles: &[solidcraft_engine::sketch::Profile]) -> impl Iterator + '_ { + let key = |ids: &[String]| { + let mut v = ids.to_vec(); + v.sort(); + v + }; + let holes: Vec> = profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| key(h))).collect(); + (0..profiles.len()).filter(move |&i| profiles.get(i).is_some_and(|p| !holes.contains(&key(&p.outer_curves)))) +} + /// Profiles of the sketch a feature would use, when there is just one: the active sketch's, else /// the last sketch's while no feature builds from it yet. A used profile (a cube's sketch on its /// bottom face) is only taken when it is picked. @@ -2566,6 +2619,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, names: crate::pref /// Saved vectors/axes are real inputs even when they have no origin-axis selection chip. fn feature_pattern_reference_key(d: &Dialog, index: usize) -> Option<&'static str> { + if matches!(d.kind, Kind::Section { .. }) && index == 0 { + return Some("plane"); + } if !d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES) { return None; } @@ -2603,7 +2659,9 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n 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(); let text = if filled { - RichText::new(if custom_axis || stored_reference.is_some() { + RichText::new(if stored_reference.is_some() && matches!(d.kind, Kind::Section { .. }) { + crate::i18n::tr("ui.viewport_status_section_plane").into() + } else if custom_axis || stored_reference.is_some() { crate::i18n::tr("dialog.custom_axis").into() } else if !inp.multi { inp.items.first().map(|item| selection_label(s, names, d, inp, item)).unwrap_or_default() @@ -2999,15 +3057,16 @@ 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 or sheet metal base or contour flange 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. +/// 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) { 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, .. } | FeatureKind::SheetBase { sketch, .. } - | FeatureKind::SheetContour { sketch, .. } => Some(**sketch), + | FeatureKind::SheetContour { sketch, .. } + | FeatureKind::Hole { points: Some(solidcraft_engine::doc::SketchPoints { sketch, single: false, .. }), .. } => Some(**sketch), _ => None, }); } @@ -3053,6 +3112,7 @@ pub(crate) fn corner_confirms(d: &Dialog) -> bool { /// Sketch Palette keeps the right there, and the dialog takes the left so both stay on screen. /// With Dialogs › In the left column it holds the left column instead (`dialog_column`). pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let escape_routed_here = !crate::viewport_status::enabled(app) || app.escape_owner.is_none_or(|owner| owner == crate::EscapeOwner::Dialog); // 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; @@ -3219,6 +3279,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let body = |ui: &mut egui::Ui| { d.sync_fillet_chain(&app.session); crate::hole_presets::rows(ui, &mut d, &mut app.preferences.hole_presets, &app.session.doc, reset_scroll); + crate::personal_sheet_rules::rows(ui, &mut d, &mut app.preferences.personal_sheet_rules, &app.session.doc, reset_scroll); let (accepted, fields) = crate::ui_kit::value_fields(ctx, || dense::rows(app, ctx, &mut d, ui)); enter |= accepted; value_fields = fields; @@ -3247,7 +3308,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { apply_again = false; } crate::ui_kit::Action::Cancel => { - if !app.esc_handled || !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if (!app.esc_handled && escape_routed_here) || !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { cancel = true; } } @@ -3280,6 +3341,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if let Some(field) = crate::ui_kit::canvas_value_focus(ctx) { value_fields.push(field); } + // Without value boxes in the view (#9479), the keyboard the window's opening or a pick gives + // the box goes to the form's value field, once the form has drawn one. + if d.focus && !crate::canvas::boxes_shown(ctx) && !value_fields.is_empty() { + d.focus = false; + d.restore_value_focus = true; + } // The editor loses the keyboard while the click is still being handled, possibly in a later // pass of the same frame, so give it back once the pointer is idle again. let pointer_busy = ctx.input(|i| i.pointer.any_down() || i.pointer.any_pressed() || i.pointer.any_released()); @@ -3302,7 +3369,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } else { d.value_focus = ctx.memory(|memory| memory.focused()).filter(|id| value_fields.iter().any(|(field, _)| field == id)); } - let preset_prompt = crate::hole_presets::name_prompt(ctx, &mut d, &mut app.preferences.hole_presets, &app.session.doc); + // Each prompt draws only for its own dialog, so both run (`|`, not `||`). + let preset_prompt = crate::hole_presets::name_prompt(ctx, &mut d, &mut app.preferences.hole_presets, &app.session.doc) + | crate::personal_sheet_rules::name_prompt(ctx, &mut d, &mut app.preferences.personal_sheet_rules, &app.session.doc); if preset_prompt { enter = false; ok = false; @@ -3320,7 +3389,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if !preset_prompt && !report_active && enter_applies && app.applying.is_none() && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; } - if !preset_prompt && !report_active && esc && !app.esc_handled { + if !preset_prompt && !report_active && esc && !app.esc_handled && escape_routed_here { app.esc_handled = true; cancel = true; } @@ -3754,10 +3823,11 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> { } let before = app.settled_cam(); app.run("sketch.create", params).map_err(|error| crate::friendly_error("sketch.create", &error))?; - app.look_at_opened_sketch(before); + let turned = app.look_at_opened_sketch(before); // On a face: look at the picked point, not the plane's origin (a corner of the face), // which left the face filling the view off-centre like a big square. - if let Sel::Face { point, .. } = sel + if turned + && let Sel::Face { point, .. } = sel && let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to) { to.target = *point; @@ -3828,16 +3898,43 @@ fn look_at_sketch_from(app: &mut SolveApp, again: bool) { let st = app.session.world_state(); if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) { let n = facing(app, ss.plane.normal(), again); - let mut to = app.cam.looking_from(n); - // Keep the sketch's own x axis to the right when looking straight down or up. - if n.z.abs() > 0.999 { - let x = ss.plane.x; - to.yaw = if n.z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; - } + let to = square_sketch_axes(app.cam.looking_from(n), ss.plane.x, ss.plane.y); app.animate_to(onto_sketch(to, ss)); } } +/// Orient the camera so the sketch's x axis runs right and its y axis is as upright as it can +/// be (#9029). Facing the plane alone left a sketch on a turned component tilted on screen. +/// Along world up/down, yaw turns the image (roll stays 0, as Top/Bottom views do); elsewhere +/// roll squares the axes. +pub(crate) fn square_sketch_axes(mut cam: solidcraft_engine::render::Camera, sketch_x: Vec3, sketch_y: Vec3) -> solidcraft_engine::render::Camera { + let b = cam.back(); + let in_view = |v: Vec3| v - b * v.dot(b); + let Some(mut x) = in_view(sketch_x).normalized() else { + return cam; + }; + // Prefer the sense of x that keeps sketch y pointing up on screen. + if in_view(sketch_y).dot(b.cross(x)) < 0.0 { + x = -x; + } + if b.dot(cam.up_dir()).abs() > 0.999 { + // Looking straight up or down: yaw alone turns the image about the view. + let framed = cam.up.to_frame(x); + let yaw = if b.dot(cam.up_dir()) > 0.0 { (-framed.y).atan2(framed.x) } else { framed.y.atan2(framed.x) }; + if yaw.is_finite() { + cam.yaw = yaw; + cam.roll = 0.0; + } + return cam; + } + let (right, up, _) = cam.basis(); + let roll = x.dot(up).atan2(x.dot(right)); + if roll.is_finite() { + cam.roll = roll; + } + cam +} + /// `to` (already facing the sketch) moved onto sketch `ss` (#3198): centred on its geometry, and /// zoomed out when that would not fit in the view; it never zooms in, so a zoom the user chose /// stays. A sketch without geometry keeps its plane's origin. Centring on the origin alone left a @@ -3925,18 +4022,42 @@ pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { if let Kind::Section { offset, flip, hatch } = &d.kind && let Some(cut) = app.session.section { - app.tree.section_dialog = Some(SectionDraft { offset: offset.clone(), flip: *flip, hatch: *hatch, plane: sels(d, 0).to_vec(), cut }); + app.tree.section_dialog = if d.extra.contains_key("plane") && sels(d, 0).is_empty() { + None // Programmatic geometry is reconstructed from the actual session, never cached. + } else { + Some(SectionDraft { offset: offset.clone(), flip: *flip, hatch: *hatch, plane: sels(d, 0).to_vec(), cut }) + }; } Ok(()) } /// Edit Section Analysis: its dialog, where the dialog that made the section shown (or hidden) -/// now left it, so the cut moves on from there. A section made some other way opens it afresh, -/// as the toolbar always does (a new section, in place of the old). +/// now left it. Programmatic cuts use their actual geometry in the same existing form. pub fn edit_section(app: &mut SolveApp) { - app.start("inspect.section"); + app.start_section_edit(); let cut = app.session.section.or(app.tree.hidden_section); - let Some(last) = app.tree.section_dialog.clone().filter(|last| cut.is_some_and(|c| same_cut(c, last.cut))) else { return }; + let last = app.tree.section_dialog.clone().filter(|last| cut.is_some_and(|c| same_cut(c, last.cut))); + if app.preferences.viewport_section_status { + if let Some((origin, normal)) = cut { + let o = [origin.x, origin.y, origin.z]; + let n = [normal.x, normal.y, normal.z]; + if let Some((_, distance, canonical)) = crate::viewport_status::section_coordinates(o, n) { + let flip = n.iter().zip(canonical).map(|(a, b)| a * b).sum::() < 0.0; + let offset = format!("{distance} mm"); + let hatch = app.session.section_hatch; + if let Some(d) = app.dialog.as_mut().filter(|d| matches!(d.kind, Kind::Section { .. })) { + d.kind = Kind::Section { offset, flip, hatch }; + if let Some(input) = d.inputs.first_mut() { + input.items.clear(); + } + let base = o.into_iter().zip(canonical).map(|(o, n)| o - n * distance).collect::>(); + d.extra.insert("plane".into(), json!({"origin":base,"normal":canonical})); + } + } + } + return; + } + let Some(last) = last else { return }; if let Some(d) = app.dialog.as_mut().filter(|d| matches!(d.kind, Kind::Section { .. })) { d.kind = Kind::Section { offset: last.offset, flip: last.flip, hatch: last.hatch }; if let Some(plane) = d.inputs.first_mut() { @@ -4639,11 +4760,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S ("material.assign", json!({"bodies": sels(d, 0).body_names(), "material": name})) } Kind::Section { offset, flip, hatch } => { - if section_turns_off(s, &d.inputs) { + if section_turns_off(s, &d.inputs) && !d.extra.contains_key("plane") { return Ok(vec![("inspect.section".into(), json!({"clear": true}))]); } - need(0, "a plane or planar face")?; + if !d.extra.contains_key("plane") { + need(0, "a plane or planar face")?; + } let plane = match sels(d, 0).first() { + None if d.extra.contains_key("plane") => d.extra.get("plane").cloned().ok_or("select a plane first")?, Some(Sel::Plane { name }) if matches!(name.as_str(), "XY" | "XZ" | "YZ") => json!(name), Some(Sel::Face { body, index, point }) => { let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?; @@ -6379,6 +6503,29 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option plane = base, + PlaneRef::Face { anchor: Some(anchor), .. } => { + let model = s.model.state(); + if let Some(body) = model.body(&anchor.target.body) { + let owner = s.doc.model_body_component(body); + let name = s.doc.world_body_name(&model, &body.name, owner, &anchor.path); + if st.body(&name).is_some() && !s.body_visible(&name) { + d.edit_source_bodies.push(name); + } + } + break; + } + _ => break, + } + } + } d.editing = Some((id, marker)); d.active = 0; d.error = None; diff --git a/crates/ui-egui/src/dialogs_dense.rs b/crates/ui-egui/src/dialogs_dense.rs index 2c0701fb3d..c404672e9a 100644 --- a/crates/ui-egui/src/dialogs_dense.rs +++ b/crates/ui-egui/src/dialogs_dense.rs @@ -193,6 +193,8 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: input_rows(d, ui, &app.session, app.preferences.origin_plane_names); } d.sync_material(&app.session); + // The Hole dialog's dropdowns preview each choice on a copy of the form as it stands. + let hole_form = matches!(d.kind, Kind::Hole { .. }).then(|| (d.kind.clone(), to_object_on(&d.inputs))); match &mut d.kind { Kind::Material { index, editor } => crate::material_editor::rows(app, ui, index, editor), Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs), @@ -523,10 +525,19 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: } } Kind::Hole { kind, opts, .. } => { + let doc = &app.session.doc; + let eval = |e: &str, k| doc.eval(e, k).ok(); + // Each option shows the hole it makes while hovered (dialogs_hole::preview). + let preview = |ui: &mut egui::Ui, choice| { + if let Some((form, to_object)) = &hole_form { + crate::dialogs_hole::preview(ui, form, *to_object, choice, &eval); + } + }; + use crate::dialogs_hole::Choice; form_row(ui, "Extents", |ui| { let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(opts.all) * 2 }; let mut ext = cur; - combo(ui, "hole_ext", &["Distance", "To Object", "All"], &mut ext); + ui_kit::dropdown_previews(ui, "hole_ext", &mut ext, &["Distance", "To Object", "All"], |ui, i| preview(ui, Choice::Extents(i))); opts.all = ext == 2; set_to_object(&mut d.inputs, &mut d.active, ext == 1); }); @@ -542,10 +553,12 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: extra_check(ui, &mut d.extra, "flip"); }); } - choice_row(ui, "Hole Type", "hole_kind", kind, &HOLE_LABELS); + form_row(ui, "Hole Type", |ui| { + ui_kit::dropdown_previews(ui, "hole_kind", kind, &HOLE_LABELS, |ui, i| preview(ui, Choice::Type(i))); + }); form_row(ui, "Hole Tap Type", |ui| { let mut tap = usize::from(opts.tapped); - combo(ui, "hole_tap", &["Simple", "Tapped"], &mut tap); + ui_kit::dropdown_previews(ui, "hole_tap", &mut tap, &["Simple", "Tapped"], |ui, i| preview(ui, Choice::Tapped(i == 1))); opts.tapped = tap == 1; }); if opts.tapped { @@ -557,14 +570,13 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: if !opts.all { form_row(ui, "Drill Point", |ui| { let mut tip = usize::from(opts.angled); - combo(ui, "hole_tip", &["Flat", "Angle"], &mut tip); + ui_kit::dropdown_previews(ui, "hole_tip", &mut tip, &["Flat", "Angle"], |ui, i| preview(ui, Choice::Angled(i == 1))); opts.angled = tip == 1; }); } // The section through the hole, each size beside its dimension (#2171). let to_object = to_object_on(&d.inputs); - let doc = &app.session.doc; - enter |= crate::dialogs_hole::rows(ui, &mut d.kind, to_object, &|e, k| doc.eval(e, k).ok()); + enter |= crate::dialogs_hole::rows(ui, &mut d.kind, to_object, &eval); } _ => enter |= additional_rows(app, d, ui), } @@ -762,7 +774,7 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo // 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")] { form_row(ui, label, |ui| { - extra_choice(ui, &mut d.extra, key, key, &[("As Picked", "picked"), ("Centre", "center"), ("Circle Centre", "circle")]); + 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)]); @@ -891,7 +903,7 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo Kind::Section { offset, flip, hatch } => { enter |= expr_row(ui, "Distance", offset); ui_kit::options(ui, "feature_options", &mut [("Flip", flip), ("Hatch", hatch)]); - if section_turns_off(&app.session, &d.inputs) { + if section_turns_off(&app.session, &d.inputs) && !d.extra.contains_key("plane") { ui_kit::text(ui, tr("section.off_hint"), ui_kit::Text::Hint("section_off")); } } @@ -927,36 +939,7 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut }); }; for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() { - // Round items: Ø first, then R, then the length (circumference, or the arc's length - // and angle). - let mut lines: Vec = Vec::new(); - if it.get("diameter").is_some() { - lines.push(format!("Ø {}", mm(&it["diameter"]))); - lines.push(format!("R {}", mm(&it["radius"]))); - if it.get("circumference").is_some() { - lines.push(format!("{} {}", tr("Circumference"), mm(&it["circumference"]))); - } else if let Some(a) = it["angle_deg"].as_f64() { - lines.push(format!("{} {}", tr("Arc length"), mm(&it["arc_length"]))); - lines.push(format!("{} {a:.2}°", crate::i18n::tr_label("Angle"))); - } - } - if let Some(sl) = it.get("slot") { - lines.push(format!("{} {}", tr("Slot width"), mm(&sl["width"]))); - lines.push(format!("{} {}", tr("Centre to centre"), mm(&sl["center_distance"]))); - lines.push(format!("{} {}", tr("Overall length"), mm(&sl["overall_length"]))); - } - let round = it.get("diameter").is_some(); - match it["type"].as_str() { - Some("edge" | "sketch_curve") if !round => lines.push(format!("{} {}", tr("Length"), mm(&it["length_mm"]))), - Some("face" | "profile") => { - lines.push(format!("{} {}", tr("Area"), crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2))) - } - Some("vertex" | "sketch_point") => { - let p = &it["position"]; - lines.push(format!("{} {}, {}, {}", tr("measure.at"), mm(&p[0]), mm(&p[1]), mm(&p[2]))); - } - _ => {} - } + let lines = crate::quick_measure::item_lines(app, it); for (k, line) in lines.into_iter().enumerate() { let label = if k == 0 { crate::i18n::format("measure.item", &[("number", &(i + 1).to_string())]) } else { String::new() }; value(ui, &label, &line); diff --git a/crates/ui-egui/src/dialogs_hole.rs b/crates/ui-egui/src/dialogs_hole.rs index 8180659975..90c8c4e1c2 100644 --- a/crates/ui-egui/src/dialogs_hole.rs +++ b/crates/ui-egui/src/dialogs_hole.rs @@ -94,12 +94,45 @@ struct Section { tip_half: f32, } -/// The section and its sizes (`ui_kit::figure_rows`): the picture in the label column, the -/// fields beside it. Returns whether Enter was accepted in a field. -pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Kind, to_object: bool, eval: &dyn Fn(&str, ValueKind) -> Option) -> bool { - let Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = k else { return false }; +/// A choice in one of the dialog's dropdowns, which [`preview`] applies to a copy of the form. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Choice { + /// Distance, To Object or All, by index. + Extents(usize), + /// Simple, Counterbore or Countersink, by index. + Type(usize), + Tapped(bool), + Angled(bool), +} + +/// A small section of the hole the form would make with `choice` picked, without its sizes: +/// what a dropdown option shows while hovered, so its name need not be known to pick it. +pub(crate) fn preview(ui: &mut egui::Ui, form: &Kind, to_object: bool, choice: Choice, eval: &dyn Fn(&str, ValueKind) -> Option) { + let mut k = form.clone(); + let mut to_object = to_object; + let Kind::Hole { kind, opts, .. } = &mut k else { return }; + match choice { + Choice::Extents(e) => { + opts.all = e == 2; + to_object = e == 1; + } + Choice::Type(t) => *kind = t, + Choice::Tapped(on) => opts.tapped = on, + Choice::Angled(on) => opts.angled = on, + } + let Some(s) = section(&k, to_object, eval) else { return }; + let t = Tokens::get(); + let (r, _) = ui.allocate_exact_size(vec2(t.form_label_width, t.control_height * 3.5), egui::Sense::hover()); + // `paint` keeps the right of its picture for dimensions; with none, the material fills `r`. + let w = r.width() / 0.64; + let pic = Rect::from_min_size(pos2(r.left() - 0.04 * w, r.top() - 6.0), vec2(w, r.height() + 6.0)); + paint(ui.painter(), pic, &s, &[]); +} + +/// What the form's hole looks like, for its picture. +fn section(k: &Kind, to_object: bool, eval: &dyn Fn(&str, ValueKind) -> Option) -> Option
{ + let Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = k else { return None }; let kind = *kind; - let dims = dims(kind, opts, to_object); let len = |e: &str| eval(e, ValueKind::Length).filter(|v| v.is_finite() && *v > 0.0); let angle = |e: &str, lo: f64, hi: f64, or: f64| eval(e, ValueKind::Angle).filter(|v| v.is_finite()).map_or(or, |v| (v / 2.0).clamp(lo, hi)); let top = if kind == 2 { cs_diameter.as_str() } else { cb_diameter.as_str() }; @@ -137,6 +170,16 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Kind, to_object: bool, eval: &dyn cs_half: angle(cs_angle.as_str(), 0.35, 1.3, std::f64::consts::FRAC_PI_4) as f32, tip_half: angle(opts.tip_angle.as_str(), 0.5, 1.3, 1.03) as f32, }; + Some(section) +} + +/// The section and its sizes (`ui_kit::figure_rows`): the picture in the label column, the +/// fields beside it. Returns whether Enter was accepted in a field. +pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Kind, to_object: bool, eval: &dyn Fn(&str, ValueKind) -> Option) -> bool { + let Some(section) = section(k, to_object, eval) else { return false }; + let Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = k else { return false }; + let kind = *kind; + let dims = dims(kind, opts, to_object); // The fields in `dims`' order, each value taken once. let mut fields: Vec<(&str, &mut String)> = Vec::with_capacity(dims.len()); match kind { @@ -480,6 +523,36 @@ mod tests { } } + #[test] + fn every_dropdown_option_previews_a_different_hole() { + let app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let form = crate::dialogs::Dialog::for_command(&app, "solid.hole").unwrap().kind; + let ctx = crate::fonts::test_context(); + let drawn = |choice: Choice| { + let mut size = egui::Vec2::ZERO; + let output = ctx.run_ui(Default::default(), |ui| { + let before = ui.min_rect(); + preview(ui, &form, false, choice, &|_, _| Some(10.0)); + size = ui.min_rect().size() - before.size(); + }); + assert!(size.x > 0.0 && size.y > 0.0, "{choice:?} drew nothing"); + output.shapes.into_iter().map(|c| c.shape).collect::>() + }; + for options in [ + vec![Choice::Extents(0), Choice::Extents(1), Choice::Extents(2)], + vec![Choice::Type(0), Choice::Type(1), Choice::Type(2)], + vec![Choice::Tapped(false), Choice::Tapped(true)], + vec![Choice::Angled(false), Choice::Angled(true)], + ] { + let pictures: Vec<_> = options.iter().map(|c| drawn(*c)).collect(); + for (i, a) in pictures.iter().enumerate() { + for (j, b) in pictures.iter().enumerate().skip(i + 1) { + assert_ne!(a, b, "{:?} and {:?} preview the same picture", options[i], options[j]); + } + } + } + } + #[test] fn partial_tapping_fields_do_not_overlap_on_narrow_forms() { for width in [220.0, 280.0, 360.0] { diff --git a/crates/ui-egui/src/dialogs_parts.rs b/crates/ui-egui/src/dialogs_parts.rs index c07b7bb674..d6f0c4b593 100644 --- a/crates/ui-egui/src/dialogs_parts.rs +++ b/crates/ui-egui/src/dialogs_parts.rs @@ -1,7 +1,8 @@ //! Insert Part: the standard parts library (screws, bolts, nuts, washers, set screws, pins, //! bearings) by family, size and length, with the size's table values and a picture of the part. -//! Picking a hole (its wall or rim) seats the part in it with a rigid joint; without one the part -//! goes at the origin. The live preview shows it placed. +//! Picking a hole (its wall or rim) seats the part in it with a rigid joint, and picking several +//! seats the same part in each; without one the part goes at the origin. The live preview shows it +//! placed. use crate::ui_kit::{choice_row, text_row}; use egui::vec2; @@ -80,7 +81,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { matches!(id, "parts.fastener" | "parts.insert" | "parts.library").then(|| { ( Kind::Part(Pt { family: 0, size: 0, length: None, name: String::new(), picture: None, lib: None }), - vec![SelInput::new("Hole (optional)", FACES | EDGES, false)], + vec![SelInput::new("parts.holes", FACES | EDGES, true)], ) }) } @@ -89,20 +90,29 @@ fn library(app: &mut SolveApp) -> Vec { app.session.execute("parts.library", &json!({})).map(|v| families(&v)).unwrap_or_default() } -/// The insert command's parameters for the picks. -pub fn insert_params(lib: &[Family], k: &Pt, hole: Option<&Sel>) -> Result { +/// The insert command's parameters for the hole picks: one part seated in each. +pub fn insert_params(lib: &[Family], k: &Pt, holes: &[Sel]) -> Result { let f = lib.get(k.family).ok_or("the parts library is empty")?; let (size, _) = f.sizes.get(k.size).ok_or("pick a size")?; let mut p = json!({"family": f.id, "size": size}); if let Some(l) = k.length.and_then(|i| f.lengths.as_ref().and_then(|ls| ls.get(i))) { p["length"] = json!(l); } - match hole { - Some(Sel::Face { point, .. } | Sel::Edge { point, .. }) => p["at"] = json!([point.x, point.y, point.z]), - _ => { + let at: Vec = holes + .iter() + .filter_map(|h| match h { + Sel::Face { point, .. } | Sel::Edge { point, .. } => Some(json!([point.x, point.y, point.z])), + _ => None, + }) + .collect(); + match at.len() { + 0 => { p["point"] = json!([0.0, 0.0, 0.0]); p["direction"] = json!([0.0, 0.0, 1.0]); } + // One hole keeps the single pick's form. + 1 => p["at"] = at.into_iter().next().unwrap_or(Value::Null), + _ => p["at"] = Value::Array(at), } if !k.name.trim().is_empty() { p["name"] = json!(k.name.trim()); @@ -183,7 +193,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu let key = (k.family, k.size, k.length); let tex_id = egui::Id::new("sc_part_picture"); if k.picture != Some(key) { - let tex = insert_params(&lib, k, None) + let tex = insert_params(&lib, k, &[]) .ok() .and_then(|p| picture(&p)) .map(|img| ui.ctx().load_texture("sc_part_picture", img, egui::TextureOptions::LINEAR)); @@ -220,11 +230,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu } if inputs.first().is_some_and(|i| i.items.is_empty()) { ui.vertical(|ui| { - crate::ui_kit::text( - ui, - crate::i18n::tr_label("pick a hole to seat it, or OK places it at the origin"), - crate::ui_kit::Text::Note("parts_placement"), - ); + crate::ui_kit::text(ui, crate::i18n::tr("parts.placement_hint"), crate::ui_kit::Text::Note("parts_placement")); }); } false @@ -236,7 +242,7 @@ pub fn commands(app: &SolveApp, k: &Pt, inputs: &[SelInput]) -> Result l.clone(), None => app.session.scratch().execute("parts.library", &json!({})).map(|v| families(&v)).unwrap_or_default(), }; - let p = insert_params(&lib, k, inputs.first().and_then(|i| i.items.first()))?; + let p = insert_params(&lib, k, inputs.first().map_or(&[][..], |i| &i.items))?; // The toolbar's command when the engine has it (same parameters). let id = if solidcraft_engine::find_command("parts.fastener").is_some() { "parts.fastener" } else { "parts.insert" }; Ok(vec![(id.into(), p)]) @@ -312,20 +318,25 @@ mod tests { assert_eq!(default_length(&l[0], 99, &ls), 0, "so does a size that is not there"); } - /// A hole pick seats the part (`at`); without one it goes to the origin along Z. + /// A hole pick seats the part (`at`), several picks seat it in each hole (#9341); without one + /// it goes to the origin along Z. #[test] fn insert_params_for_picks() { let l = lib(); let k = Pt { family: 0, size: 1, length: Some(2), name: String::new(), picture: None, lib: None }; let hole = Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(5.0, 0.0, 10.0) }; - let p = insert_params(&l, &k, Some(&hole)).unwrap(); + let p = insert_params(&l, &k, std::slice::from_ref(&hole)).unwrap(); assert_eq!(p, json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20.0, "at": [5.0, 0.0, 10.0]})); + let rim = Sel::Edge { body: "Body1".into(), index: 7, point: Vec3::new(25.0, 0.0, 10.0) }; + let p = insert_params(&l, &k, &[hole, rim]).unwrap(); + assert_eq!(p["at"], json!([[5.0, 0.0, 10.0], [25.0, 0.0, 10.0]])); + assert!(p.get("point").is_none()); let k = Pt { family: 1, size: 0, length: None, name: "Front bearing".into(), picture: None, lib: None }; - let p = insert_params(&l, &k, None).unwrap(); + let p = insert_params(&l, &k, &[]).unwrap(); assert_eq!(p["point"], json!([0.0, 0.0, 0.0])); assert_eq!(p["name"], "Front bearing"); assert!(p.get("length").is_none()); - assert!(insert_params(&[], &k, None).is_err()); + assert!(insert_params(&[], &k, &[]).is_err()); } /// Every dimension the library gives for a size is labelled in words, in each family. diff --git a/crates/ui-egui/src/dialogs_save_mesh.rs b/crates/ui-egui/src/dialogs_save_mesh.rs index 495a4e5160..c4ec7b72c4 100644 --- a/crates/ui-egui/src/dialogs_save_mesh.rs +++ b/crates/ui-egui/src/dialogs_save_mesh.rs @@ -3,6 +3,10 @@ //! send the bodies to: it always asks, and pre-selects the slicer used last time. The list also has //! a plain text row for EasyPrint (`file.open_in_easyprint`), which works in a browser too. //! +//! Send to Slicer also has a Print face input (#8429): it shows the print face of the one body being +//! sent, takes a flat face picked in the viewport as that body's print face, and clears it when +//! emptied. Each change runs `body.print_face` at once, so it undoes like any other. +//! //! Every place that saves a mesh opens this dialog: File › Export STL / Export 3MF, the Mesh tab, and the //! Save As Mesh item of the body menus. The slicer list (Send to Slicer only) holds the programs the user added (kept //! between runs, newest first), then the slicers found on this machine, then "Custom…", which asks @@ -19,7 +23,7 @@ use solidcraft_engine::slicers::{self, Slicer}; use crate::SolveApp; use crate::dialogs::{Kind, form_row}; -use crate::selection::{BODIES, SelInput}; +use crate::selection::{BODIES, PLANAR_FACES, SelInput}; use crate::theme::Tokens; use solidcraft_engine::Sel; @@ -47,6 +51,9 @@ pub struct SaveMesh { slicer: String, /// The slicer list has not been read for this opening yet. stale: bool, + /// Send to Slicer: the bodies and the print face the Print face input last showed, to tell + /// a pick or a clear in it from a change of the bodies. + shown: Option<(Vec, Option)>, } impl SaveMesh { @@ -128,8 +135,12 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { _ => String::new(), }; let quality = QUALITIES.iter().position(|q| q.1 == p.save_mesh_quality).unwrap_or(0); - let kind = Kind::SaveMesh(SaveMesh { format, quality, open, slicer, stale: true }); - Some((kind, vec![SelInput::new("Bodies", BODIES, true)])) + let kind = Kind::SaveMesh(SaveMesh { format, quality, open, slicer, stale: true, shown: None }); + let mut inputs = vec![SelInput::new("Bodies", BODIES, true)]; + if open { + inputs.push(SelInput::new("Print face", PLANAR_FACES, false)); + } + Some((kind, inputs)) } /// Send to Slicer needs a way to start a program (a browser has none) or a way to reach EasyPrint. @@ -310,8 +321,44 @@ pub(crate) fn body_picks(app: &SolveApp, visible: bool) -> Vec { st.bodies.iter().filter(|b| shown(&b.name)).map(|b| Sel::Body { name: b.name.clone() }).collect() } +/// Keep the Print face input and the design in step. A face picked into it becomes the print +/// face of its body, which must be the one body being sent; emptying it clears that body's print +/// face. When the bodies change, it shows the print face of the one body, or nothing for several. +fn sync_print_face(app: &mut SolveApp, k: &mut SaveMesh, inputs: &mut [SelInput]) { + let bodies = crate::selection::items(inputs, 0).body_names(); + let Some(input) = inputs.get_mut(1) else { return }; + let picked = input.items.first().cloned(); + match k.shown.take() { + Some((shown_bodies, shown)) if shown_bodies == bodies && shown != picked => { + let one = bodies.first().filter(|_| bodies.len() == 1).cloned(); + let target = match (&picked, &shown) { + (Some(Sel::Face { body, .. }), _) => Some(body.clone()), + (None, Some(Sel::Face { body, .. })) => Some(body.clone()), + _ => None, + }; + match (target, one) { + (Some(body), Some(one)) if body == one => { + crate::print_face_ui::set(app, &body, picked.as_ref()); + } + _ => app.set_status(crate::i18n::tr("ui.print_face_one_body"), true), + } + } + Some(shown) if shown.0 == bodies => { + k.shown = Some(shown); + return; + } + _ => {} + } + let current = bodies.first().filter(|_| bodies.len() == 1).and_then(|b| crate::print_face_ui::current(app, b)); + input.items = current.iter().cloned().collect(); + k.shown = Some((bodies, current)); +} + /// The rows after the Bodies input; true when Enter was pressed in a field. pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh, inputs: &mut [SelInput]) -> bool { + if k.open { + sync_print_face(app, k, inputs); + } // One row each: the two side by side do not fit the dialog's width. for (label, visible) in [("Add all bodies", false), ("Add visible bodies", true)] { form_row(ui, "", |ui| { @@ -879,7 +926,7 @@ mod tests { #[test] fn send_to_slicer_asks_for_bodies_and_a_slicer_but_not_a_format() { - let k = SaveMesh { format: 0, quality: 0, open: true, slicer: "prusa".into(), stale: false }; + let k = SaveMesh { format: 0, quality: 0, open: true, slicer: "prusa".into(), stale: false, shown: None }; let mut input = SelInput::new("Bodies", BODIES, true); assert!(commands(&k, std::slice::from_ref(&input)).is_err()); input.items = vec![Sel::Body { name: "Part".into() }]; @@ -898,7 +945,7 @@ mod tests { fn a_remembered_quality_goes_to_save_but_not_to_send() { let mut input = SelInput::new("Bodies", BODIES, true); input.items = vec![Sel::Body { name: "Part".into() }]; - let k = SaveMesh { format: 0, quality: 2, open: true, slicer: "prusa".into(), stale: false }; + let k = SaveMesh { format: 0, quality: 2, open: true, slicer: "prusa".into(), stale: false, shown: None }; let (_, sent) = commands(&k, std::slice::from_ref(&input)).unwrap().remove(0); assert_eq!(sent.get("quality"), None, "sending has no quality row: the default applies"); let (_, saved) = commands(&SaveMesh { open: false, ..k }, &[input]).unwrap().remove(0); diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 2d4998443a..a0dbf6bde1 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -31,7 +31,7 @@ const POSITION_LABELS: [&str; 5] = ["Inside", "Outside", "Middle", "Material Out const HEIGHT_FROM: [&str; 3] = ["outer", "inner", "tangent"]; const HEIGHT_FROM_LABELS: [&str; 3] = ["Outer Sharp", "Inner Sharp", "Tangent"]; /// Rule fields the manager edits (parameter name, label). -const RULE_FIELDS: [(&str, &str); 8] = [ +pub(crate) const RULE_FIELDS: [(&str, &str); 8] = [ ("thickness", "Thickness"), ("k_factor", "K Factor"), ("bend_radius", "Bend Radius"), @@ -42,11 +42,11 @@ const RULE_FIELDS: [(&str, &str); 8] = [ ("gap", "Flange Gap"), ]; /// The corner relief shapes the manager offers (the rule's `corner_relief_shape`) and their labels. -const RELIEF_SHAPES: [&str; 4] = ["notch", "round", "rectangular", "tear"]; +pub(crate) const RELIEF_SHAPES: [&str; 4] = ["notch", "round", "rectangular", "tear"]; const RELIEF_SHAPE_LABELS: [&str; 4] = ["Notch", "Round", "Rectangular", "Tear"]; /// How the manager's rule reads bend angles in the DXF bend notes (the rule's `angle_display`) /// and their labels. -const ANGLE_DISPLAYS: [&str; 2] = ["bend", "included"]; +pub(crate) const ANGLE_DISPLAYS: [&str; 2] = ["bend", "included"]; const ANGLE_DISPLAY_LABELS: [&str; 2] = ["Bend", "Included"]; /// The corner relief shapes an edge flange offers: the rule's (index 0, nothing of its own), then /// the shapes of `RELIEF_SHAPES` in order. @@ -208,7 +208,13 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { /// The design's rules (the default steel rule when it has none). fn rule_list(app: &SolveApp) -> Vec { - let r = &app.session.doc.sheet.rules; + design_rules(&app.session.doc) +} + +/// The rules the manager lists for this design: its own, or the default steel rule when it has +/// none. +pub(crate) fn design_rules(doc: &solidcraft_engine::doc::Document) -> Vec { + let r = &doc.sheet.rules; if r.is_empty() { vec![SheetRule::steel()] } else { r.clone() } } diff --git a/crates/ui-egui/src/edit_clipboard.rs b/crates/ui-egui/src/edit_clipboard.rs index 114c14ccbd..70033ce997 100644 --- a/crates/ui-egui/src/edit_clipboard.rs +++ b/crates/ui-egui/src/edit_clipboard.rs @@ -15,7 +15,8 @@ //! //! Copied bodies paste into another design (tab) too, as imported bodies without their history, //! and copied components as independent copies with their history (Paste and Paste New alike), -//! where they were in the world (`component.paste_from_design`); cut ones paste there as copies, +//! 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 //! clipboard empties when another design copied after it. //! @@ -154,7 +155,19 @@ pub fn pasting_components(app: &SolveApp) -> bool { /// Whether copied features (sketches, …) are what Paste pastes now (a Sketches folder's menu /// offers them). pub fn pasting_features(app: &SolveApp) -> bool { - app.session.active_sketch.is_none() && app.linked_design_clipboard.is_none() && !app.session.clipboard.is_empty() + app.session.active_sketch.is_none() + && app.linked_design_clipboard.is_none() + && (!app.session.clipboard.is_empty() || foreign(app).is_some_and(|c| !c.features.is_empty())) +} + +/// The same engine paste serves the keyboard and the Sketches folder/group menus. +fn paste_features(app: &mut SolveApp, component: u64, path: Vec) -> Result { + let copy = foreign(app).filter(|c| !c.features.is_empty()).cloned(); + let from_design = copy.is_some(); + if let Some(copy) = copy { + app.session.foreign_copy = Some(copy); + } + app.run("timeline.paste", json!({ "component": component, "component_path": path, "from_design": from_design })) } /// Whether copied or cut bodies are what Paste pastes now (a Bodies folder's menu offers them). @@ -171,7 +184,7 @@ pub fn pasting_bodies(app: &SolveApp) -> bool { pub fn paste_into_group(app: &mut SolveApp, at: Option, into: Option<(u64, Vec)>, group: Option, folder: &str) -> bool { let (Some(group), Some((component, path))) = (group, into.clone()) else { return paste_into(app, at, false, into) }; if folder == "sketches" && pasting_features(app) { - let Ok(out) = app.run("timeline.paste", json!({ "component": component, "component_path": path })) else { return false }; + let Ok(out) = paste_features(app, component, path) else { return false }; let items: Vec = out .get("pasted") .and_then(Value::as_array) @@ -314,6 +327,7 @@ pub fn copy(app: &mut SolveApp) -> bool { } let Ok(out) = app.run("timeline.copy", json!({ "features": features })) else { return copy_failed(app, None) }; replace_clipboard(app); + app.foreign_copy = app.session.foreign_copy.clone(); let names: Vec<&str> = out.get("copied").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); app.set_status(format!("Copied {}", names.join(", ")), false); leave_text(app, format!("SolidCraft features: {}", names.join(", "))); @@ -587,6 +601,9 @@ fn paste_placement(app: &SolveApp, at: Option) -> (Vec, [f64; 3]) { /// as imported bodies, or its components as independent copies, where they were in the world; /// they are selected. fn paste_from_design(app: &mut SolveApp, copy: std::sync::Arc, (component, path): (u64, Vec)) -> bool { + if !copy.features.is_empty() { + return paste_features(app, component, path).is_ok(); + } let design = copy.design.clone(); app.session.foreign_copy = Some(copy); let Ok(out) = app.run("component.paste_from_design", json!({ "component": component, "component_path": path })) else { return false }; diff --git a/crates/ui-egui/src/edit_clipboard_tests.rs b/crates/ui-egui/src/edit_clipboard_tests.rs index ed12e6f919..e37e8d7d92 100644 --- a/crates/ui-egui/src/edit_clipboard_tests.rs +++ b/crates/ui-egui/src/edit_clipboard_tests.rs @@ -206,6 +206,47 @@ fn ctrl_c_ctrl_v_outside_a_sketch_copies_and_pastes_timeline_features() { assert_eq!(h.app.session.doc.features.len(), 2, "{:?}", h.app.status); } +#[test] +fn completed_sketch_copies_and_pastes_into_a_new_design() { + let (mut h, _) = rectangle(); + h.app.run("sketch.finish", json!({})).unwrap(); + let id = h.app.session.doc.features[0].id; + select(&mut h.app, json!({"type": "feature", "id": id})); + clipboard_event(&mut h, egui::Event::Copy, None); + crate::documents::new_tab(&mut h.app); + clipboard_event(&mut h, egui::Event::Paste("SolidCraft features".into()), None); + assert_eq!(h.app.session.doc.features.len(), 1, "{:?}", h.app.status); + let copied = &h.app.session.doc.features[0]; + let sketch = h.app.session.doc.sketch(copied.id).unwrap(); + assert_eq!(sketch.curves.len(), 4); + assert!(h.app.session.model.result(copied.id).unwrap().error.is_none()); +} + +#[test] +fn copied_sketch_survives_source_close_and_pastes_from_a_sketch_group_menu() { + use crate::context_menu::{Target, items, run_item}; + let (mut h, _) = rectangle(); + h.app.run("sketch.finish", json!({})).unwrap(); + select(&mut h.app, json!({"type":"feature","id":1})); + assert!(super::copy(&mut h.app)); + crate::documents::new_tab(&mut h.app); + crate::documents::close(&mut h.app, 0, true); + assert!(super::can_paste(&h.app) && super::pasting_features(&h.app)); + assert!(!super::pasting_bodies(&h.app) && !super::pasting_components(&h.app)); + let group = h.app.run("browser.group", json!({"folder":"sketches","name":"Copied"})).unwrap()["group"].as_u64().unwrap(); + let item = items(&h.app, &Target::Group { id: group, scope: "c0".into() }).into_iter().find(|i| i.id == "ui.paste").unwrap(); + assert!(item.enabled); + run_item(&mut h.app, &item, egui::Pos2::ZERO); + let id = h.app.session.doc.find_feature("Sketch1").unwrap().id; + assert_eq!(h.app.session.doc.sketch(id).unwrap().curves.len(), 4); + assert!(h.app.session.doc.browser_groups.iter().any(|g| g.id == group && g.has_item(&id.to_string()))); + // A later copy in this design replaces the cross-design snapshot everywhere. + select(&mut h.app, json!({"type":"feature","id":id})); + assert!(super::copy(&mut h.app)); + assert!(super::paste(&mut h.app, None)); + assert!(h.app.session.doc.find_feature("Sketch1 (2)").is_some()); +} + #[test] fn ctrl_x_cuts_the_sketch_selection_and_ctrl_v_brings_it_back() { let (mut h, rect) = rectangle(); @@ -754,9 +795,7 @@ fn pasting_on_a_group_puts_the_copy_in_it() { assert!(item.enabled); run_item(app, &item, egui::Pos2::ZERO); }; - let holds = |app: &crate::SolveApp, group: u64, key: &str| { - app.session.doc.browser_groups.iter().any(|g| g.id == group && g.items.iter().any(|k| k == key)) - }; + let holds = |app: &crate::SolveApp, group: u64, key: &str| app.session.doc.browser_groups.iter().any(|g| g.id == group && g.has_item(key)); select(&mut app, json!({"type": "body", "name": "Cube"})); assert!(super::copy(&mut app)); paste_on(&mut app, bodies); diff --git a/crates/ui-egui/src/expression_complete.rs b/crates/ui-egui/src/expression_complete.rs index c8f7f7725b..71c8f66ff9 100644 --- a/crates/ui-egui/src/expression_complete.rs +++ b/crates/ui-egui/src/expression_complete.rs @@ -3,7 +3,7 @@ use crate::ui_kit; use egui::Key; -use crate::params_dialog::{exact_token, suggestions_for, unit_token, word_before}; +use crate::params_dialog::{every_parameter, exact_token, suggestions_for, unit_token, word_before}; use crate::theme::Tokens; #[derive(Clone)] @@ -31,6 +31,10 @@ struct Popup { /// every frame: the names it depends on are gathered only once a box has the focus, and /// the text before the word can change under it (`20 mm` replaced by `pi mm`). keyed: bool, + /// The box was empty when last drawn: Down arrow there opens the list. + empty: bool, + /// Down arrow in the empty box opened it: every named parameter, however many (#9246). + every: bool, } fn popup_id() -> egui::Id { @@ -53,6 +57,22 @@ pub(crate) fn begin_frame(ctx: &egui::Context) { (popup.armed, popup.keyed) = (true, true); ctx.data_mut(|d| d.insert_temp(popup_id(), popup.clone())); } + // Down arrow in an empty box opens every named parameter (#9246): one need not remember + // a letter of the name. Once the list is open, Down moves through it as below. + if popup.empty + && (popup.count == 0 || popup.dismissed) + && ctx.memory(|m| m.has_focus(popup.editor)) + && popup.frame.saturating_add(1) >= ctx.cumulative_frame_nr() + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::ArrowDown)) + { + ctx.input_mut(|i| { + i.raw.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::ArrowDown, modifiers, .. } if modifiers.is_none())); + }); + (popup.explicit, popup.every, popup.dismissed) = (true, true, false); + (popup.armed, popup.keyed, popup.selected) = (true, true, 0); + ctx.data_mut(|d| d.insert_temp(popup_id(), popup.clone())); + return; + } let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor)); let focused = ctx.memory(|m| m.has_focus(popup.editor)) || escape_blurred; if popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() || !focused || popup.dismissed || popup.count == 0 { @@ -169,7 +189,10 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri rows: Vec::new(), armed, keyed: false, + empty: false, + every: false, }); + popup.empty = text.is_empty(); popup.armed = popup.keyed || armed; // Never over a control being dragged: the drag dismisses them. if ctx.dragged_id().is_some_and(|id| id != resp.id) { @@ -178,7 +201,9 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri // A unit right after a number (`2 in`, `10 mm`, the form every dialog starts its sizes in) // is a unit, not the start of a name: no popup there, so nothing sits over the OK button. let number_before = before.is_some_and(|c| c.is_ascii_digit() || c == '.' || c == ')'); - let list = if !popup.explicit && number_before && unit_token(&ctx, &popup.word) { + let list = if popup.every { + every_parameter(&ctx) + } else if !popup.explicit && number_before && unit_token(&ctx, &popup.word) { Vec::new() } else { suggestions_for(&ctx, &popup.word, popup.explicit) @@ -326,6 +351,12 @@ mod tests { output.textures_delta.clear(); } + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(Vec::new()); + } + } + fn key(&mut self, key: Key) { self.frame(vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]); } @@ -649,6 +680,54 @@ mod tests { } } + /// #9246: Down arrow in an empty box lists every named parameter, past the eight rows a + /// typed word shows; the list scrolls to the chosen row and Enter inserts it. In a box that + /// already holds text, Down stays the editor's. + #[test] + fn down_arrow_in_an_empty_box_lists_every_parameter_and_scrolls() { + let mut e = Editor::new(""); + for i in 1..=15 { + e.app.run("parameters.add", json!({"name":format!("p{i:02}"),"expression":"1 mm"})).unwrap(); + } + e.frame(Vec::new()); + assert_eq!(e.popup(), None, "an empty box shows nothing until asked"); + e.key(Key::ArrowDown); + e.frame(Vec::new()); + assert!(!e.leaked.contains(&Key::ArrowDown)); + assert_eq!(e.popup(), Some((20, true)), "every parameter, armed on the first"); + assert_eq!(e.selected(), 0); + assert!(e.text.is_empty()); + + e.key(Key::ArrowUp); + e.frames(2); + assert_eq!(e.selected(), 19); + let rows = e.ctx.data(|d| d.get_temp::(popup_id())).unwrap().rows; + assert!(rows.last().is_some_and(|r| r.area() > 0.0), "the chosen last row is scrolled into view"); + assert!(rows.first().is_some_and(|r| r.area() <= 0.0), "the first row has scrolled out: {:?}", rows.first()); + let last = params_dialog::every_parameter(&e.ctx).last().map(|s| s.text.clone()).unwrap(); + e.key(Key::Enter); + assert!(e.accepted); + assert_eq!(e.text, last); + + let mut typed = Editor::new("width + "); + typed.key(Key::ArrowDown); + assert!(typed.leaked.contains(&Key::ArrowDown), "a box with text keeps Down for itself"); + assert_eq!(typed.popup(), None); + + // Preferences caps the list, so a large design does not list thousands; a hand-edited + // cap below the accepted range still lists a few. + for (cap, listed) in [(12, 12), (0, 5)] { + let mut capped = Editor::new(""); + for i in 1..=15 { + capped.app.run("parameters.add", json!({"name":format!("p{i:02}"),"expression":"1 mm"})).unwrap(); + } + capped.app.preferences.parameter_list_rows = cap; + capped.key(Key::ArrowDown); + capped.frame(Vec::new()); + assert_eq!(capped.popup(), Some((listed, true)), "cap {cap}"); + } + } + #[test] fn modified_navigation_remains_available_to_the_editor() { let mut e = Editor::new("wi"); diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index 0b271fea88..47fead1964 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -503,3 +503,73 @@ fn two_sides_with_all_shows_distance_2_and_reverse_swaps_the_sides() { h.step(&json!({"widget":"OK"})).unwrap(); assert_eq!(extent(&h), e); } + +/// Without value boxes in the view (Preferences, #9479), nothing covers the model: the command +/// window's Distance takes the keyboard instead, its text selected, and the arrow still drags it. +#[test] +fn without_value_boxes_in_the_view_the_distance_is_typed_in_the_window() { + let mut h = make(json!({"distance":"4 mm"})); + h.app.preferences.value_boxes_in_view = false; + open(&mut h); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_canvas_area"))).is_none(), "a value box in the view"); + let focused = h.ctx.memory(|m| m.focused()); + assert!(focused.is_some() && focused != Some(egui::Id::new("sc_canvas_value")), "{focused:?}"); + h.step(&json!({"text":"25 mm"})).unwrap(); + h.frames(2); + assert!((dialog_distance(&h) - 25.0).abs() < 1e-9, "{}", dialog_distance(&h)); + h.step(&json!({"drag":[{"handle":"arrow"},on_arrow(&h, 30.0)]})).unwrap(); + assert!((dialog_distance(&h) - 30.0).abs() < 0.5, "{}", dialog_distance(&h)); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_canvas_area"))).is_none(), "a value box in the view"); +} + +#[test] +fn editing_face_extrusion_temporarily_shows_its_hidden_source_body() { + for finish in ["Cancel", "OK"] { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Source"})).unwrap(); + h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30,0,0],"body_name":"Other"})).unwrap(); + h.app + .run("solid.extrude", json!({"face":[5,5,10],"face_body":"Source","distance":5,"operation":"new","body_name":"Extension","name":"Depth"})) + .unwrap(); + h.app.session.visibility.hidden_bodies.push("Source".into()); + let visibility = h.app.session.visibility.clone(); + let undo = h.app.session.undo.len(); + open(&mut h); + assert!(h.app.body_visible("Source")); + assert!(!h.app.session.body_visible("Source"), "the editor must not change persistent visibility"); + assert!(h.app.body_visible("Other")); + assert!(h.app.session.world_state().body("Extension").is_none(), "the feature's result is rolled off"); + assert_eq!(h.app.session.undo.len(), undo); + h.app.session.visibility.hidden_bodies.push("Other".into()); + h.step(&json!({"widget":finish})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + assert!(!h.app.body_visible("Source")); + assert!(!h.app.body_visible("Other"), "unrelated visibility edits are preserved"); + assert_eq!(h.app.session.visibility.hidden_sketches, visibility.hidden_sketches); + assert_eq!(h.app.session.visibility.shown_sketches, visibility.shown_sketches); + assert!(h.app.session.world_state().body("Extension").is_some()); + } +} + +#[test] +fn failed_face_extrusion_edit_keeps_context_until_escape_and_preserves_visibility() { + let mut h = Harness::new(); + let owner = h.app.run("component.create", json!({"name":"Part"})).unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Source"})).unwrap(); + h.app.run("solid.extrude", json!({"face":[5,5,10],"face_body":"Source","distance":5,"operation":"new","name":"Depth"})).unwrap(); + let path = vec![owner["occurrence"].as_u64().unwrap()]; + h.app.session.visibility.hidden_occurrences.push(path); + let visibility = h.app.session.visibility.clone(); + assert!(!h.app.body_visible("Source")); + open(&mut h); + assert!(h.app.body_visible("Source"), "a hidden parent must not hide the source during editing"); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"missing_length"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_some()); + assert!(h.app.body_visible("Source")); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert!(!h.app.body_visible("Source")); + assert_eq!(h.app.session.visibility, visibility); +} diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs index 9dd6e22003..ad301a0506 100644 --- a/crates/ui-egui/src/file_load_ui_tests.rs +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -12,13 +12,15 @@ struct Win { ctx: egui::Context, app: SolveApp, time: f64, + /// The window's size in points; a test that changes it moves the viewport's layout. + size: egui::Vec2, } impl Win { fn new() -> Self { let mut app = SolveApp::new(Session::default(), Default::default()); app.custom_titlebar = true; - let mut w = Win { ctx: crate::fonts::test_context(), app, time: 0.0 }; + let mut w = Win { ctx: crate::fonts::test_context(), app, time: 0.0, size: vec2(1600.0, 1000.0) }; for _ in 0..3 { w.frame(vec![]); } @@ -29,7 +31,7 @@ impl Win { fn frame(&mut self, events: Vec) -> Vec { self.time += 1.0 / 60.0; let raw = RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.size)), time: Some(self.time), focused: true, events, @@ -673,3 +675,77 @@ fn cancelling_a_step_open_releases_the_slot_before_the_worker_returns() { assert_eq!(fingerprint(&w.app.session), fingerprint(&expected)); let _ = std::fs::remove_dir_all(&dir); } + +/// An opened design comes up at the home view, framed on its own model: the angle the design +/// before it was left at doesn't carry over, and a design opened while the Home screen is over +/// the viewport is framed when the viewport is drawn, not against the layout of what was there +/// before (which left the design wherever the camera happened to be). +#[test] +fn an_opened_design_comes_up_at_the_home_view() { + use solidcraft_engine::render::StandardView; + let dir = scratch("home-view"); + let path = large_design(&dir, 4); + let home_angles = { + let mut cam = solidcraft_engine::render::Camera::default(); + cam.set_view(StandardView::Iso); + (cam.yaw, cam.pitch, cam.roll) + }; + let mut w = Win::new(); + // The view the design before it was left at: straight down, zoomed right in. + w.app.cam.set_view(StandardView::Top); + w.app.cam.distance = 12.0; + w.app.cam.target = solidcraft_engine::geom::Vec3::new(50.0, -70.0, 3.0); + // Opened from the Home screen, which covers the viewport while the design goes in: the + // viewport is not drawn, so the layout a fit would be framed into is the one left over from + // what was there before, which cannot frame anything. + let revision = w.app.session.revision; + w.app.home.show(revision); + let stale = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)); + w.app.open_path_async(&path); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + while w.app.file_load.busy() { + w.app.viewport.rect = Some(stale); + w.frame(vec![]); + assert!(std::time::Instant::now() < deadline, "the open finishes"); + std::thread::yield_now(); + } + // The design is meshed and framed while the Home screen is still over the viewport, as it is + // for a design opened from it: nothing draws the viewport in between, so nothing frames the + // design again afterwards. + while crate::open_meshing::active(&w.app) { + w.app.viewport.rect = Some(stale); + w.frame(vec![]); + assert!(std::time::Instant::now() < deadline, "the meshing finishes"); + std::thread::yield_now(); + } + for _ in 0..4 { + w.app.viewport.rect = Some(stale); + w.frame(vec![]); + } + // The Home screen closes and the viewport is drawn for the first time since the open. + w.app.home.open = false; + for _ in 0..4 { + w.frame(vec![]); + } + assert_eq!((w.app.cam.yaw, w.app.cam.pitch, w.app.cam.roll), home_angles, "opened at the home view"); + // Framed on its model, as a fit asked for now frames it: not left at the default camera. + let opened = w.app.cam; + w.app.fit_view(); + let refitted = w.app.cam; + assert!((refitted.target - opened.target).len() < 1e-6, "framed on the model: {opened:?} vs {refitted:?}"); + assert!((refitted.distance - opened.distance).abs() < 1e-6, "framed on the model: {opened:?} vs {refitted:?}"); + // A synchronous open (the control channel's) into a blank design, which keeps its tab and so + // its camera, comes up at the home view too. + let other = scratch("home-view-sync"); + let second = large_design(&other, 2); + crate::documents::new_design(&mut w.app); + w.frame(vec![]); + w.app.cam.set_view(StandardView::Front); + w.app.open_path(&second); + for _ in 0..4 { + w.frame(vec![]); + } + assert_eq!((w.app.cam.yaw, w.app.cam.pitch, w.app.cam.roll), home_angles, "a synchronous open too"); + let _ = std::fs::remove_dir_all(&other); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index bdffc5ac25..598f7fe564 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -1,6 +1,7 @@ //! Bundled IBM Plex interface faces, user overrides, and language-specific system fallbacks //! (CJK, Hangul, Thai, Hebrew). -//! The same definitions are used on desktop and web; the web shell adds Japanese on demand. +//! The same definitions are used on desktop and web; the web shell adds Japanese and Chinese +//! faces on demand. use std::sync::Arc; @@ -171,8 +172,7 @@ pub fn with_symbol_fallback(mut fonts: egui::FontDefinitions) -> egui::FontDefin fonts } -/// UI languages whose text needs the CJK font. The web app's bundled font covers Japanese only; -/// add Chinese or Korean here together with a font that covers them. +/// UI languages whose text needs a CJK script font on the web. pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "zh-TW", "ko"]; /// Whether the UI language needs a CJK font (the web app fetches one only then). @@ -180,6 +180,22 @@ pub fn needs_cjk_font(language: &str) -> bool { CJK_LANGUAGES.contains(&language) } +/// Whether the UI language needs the Simplified or Traditional Chinese web font (#8894). +pub fn needs_chinese_font(language: &str) -> bool { + matches!(language, "zh" | "zh-TW") +} + +/// Which on-demand web face a CJK UI language loads: `"zh"`, `"zh-TW"`, or `"ja"` (also for +/// Korean until a Hangul face ships). `None` when the language uses only the bundled Latin faces. +pub fn web_script_font_kind(language: &str) -> Option<&'static str> { + match language { + "zh" => Some("zh"), + "zh-TW" => Some("zh-TW"), + "ja" | "ko" => Some("ja"), + _ => None, + } +} + /// Whether `text` has characters only the CJK fallback draws: kana, CJK ideographs and their /// punctuation, and full-width forms. Hangul is left out: the web app's font has none. pub fn has_cjk(text: &str) -> bool { @@ -245,19 +261,48 @@ fn promote_font_to_front(fonts: &mut FontDefinitions, name: &str) { } } -/// Install the downloaded font with the CJK UI's shared mixed-script baseline. +/// Install the downloaded Japanese font with the CJK UI's shared mixed-script baseline. pub fn install_cjk_font_for(ctx: &egui::Context, bytes: Vec, language: &str) -> bool { - let fonts = ctx.data(|d| d.get_temp::(installed_fonts_id())).unwrap_or_else(base_font_definitions); - match with_cjk_fallback(fonts, bytes) { - Some(mut fonts) => { - if needs_cjk_font(language) { - promote_font_to_front(&mut fonts, CJK_FALLBACK); - } - install(ctx, fonts); - true - } - None => false, + install_web_script_fonts(ctx, Some(bytes), None, language) +} + +/// Install the web shell's on-demand Japanese and/or Chinese faces, ordered for `language`. +/// Returns false only when at least one byte blob was supplied and none of them parsed as a font. +pub fn install_web_script_fonts(ctx: &egui::Context, cjk: Option>, chinese: Option>, language: &str) -> bool { + if cjk.is_none() && chinese.is_none() { + setup_fonts_for(ctx, language, ""); + return true; } + let mut fonts = ctx.data(|d| d.get_temp::(installed_fonts_id())).unwrap_or_else(base_font_definitions); + // Drop prior script faces so a language change can replace them without stacking duplicates. + for name in [CJK_FALLBACK, CHINESE_FALLBACK] { + fonts.font_data.remove(name); + for family in all_families() { + if let Some(list) = fonts.families.get_mut(&family) { + list.retain(|entry| entry != name); + } + } + } + let mut any = false; + for (name, bytes) in [(CJK_FALLBACK, cjk), (CHINESE_FALLBACK, chinese)] { + let Some(bytes) = bytes else { continue }; + any = true; + if let Some(next) = with_fallback(fonts.clone(), name, bytes) { + fonts = next; + } + } + if any && !fonts.font_data.contains_key(CJK_FALLBACK) && !fonts.font_data.contains_key(CHINESE_FALLBACK) { + return false; + } + if needs_cjk_font(language) + && let Some(primary) = fallback_order(language) + .into_iter() + .find(|name| [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK].contains(name) && fonts.font_data.contains_key(*name)) + { + promote_font_to_front(&mut fonts, primary); + } + install(ctx, fonts); + true } /// Lower-case file-name fragments that identify CJK fonts. Deliberately specific: generic @@ -319,8 +364,8 @@ pub fn is_chinese_font_name(file_name: &str) -> bool { CHINESE_FONT_KEYWORDS.iter().any(|k| name.contains(k)) } -/// Session state of the web app's on-demand CJK font. Pure state so it can be tested off the web: -/// the shell does the fetch when [`CjkFontLoader::tick`] asks for it. +/// Session state of the web app's on-demand script fonts. Pure state so it can be tested off the +/// web: the shell does the fetch when [`CjkFontLoader::tick`] asks for it. #[derive(Debug, Default)] pub struct CjkFontLoader { status: CjkFontStatus, @@ -342,19 +387,28 @@ impl CjkFontLoader { self.status } - /// Called every frame with the live language. Returns true when the font fetch should start - /// now (only for a CJK language, only once). After a failed load a CJK language falls back to - /// English for this session; the saved preference is kept (see [`Self::persisted_language`]). - pub fn tick(&mut self, language: &mut String) -> bool { + /// Called every frame with the live 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 + /// a face starts another fetch (#8894). + pub fn tick(&mut self, language: &mut String, ready: bool) -> bool { if self.saved_language.is_some() && language != "en" { // The user picked a language themselves since the fallback: that is the preference now. self.saved_language = None; + if self.status == CjkFontStatus::Failed { + self.status = CjkFontStatus::Unloaded; + } } if !needs_cjk_font(language) { return false; } + if ready { + self.status = CjkFontStatus::Installed; + return false; + } match self.status { - CjkFontStatus::Unloaded => { + CjkFontStatus::Unloaded | CjkFontStatus::Installed => { self.status = CjkFontStatus::Loading; true } @@ -362,12 +416,12 @@ impl CjkFontLoader { self.fall_back(language); false } - CjkFontStatus::Loading | CjkFontStatus::Installed => false, + CjkFontStatus::Loading => false, } } - /// CJK text is on screen (whatever the UI language). Returns true when the font fetch should - /// start now: once, and never again after a failed load (the UI language stays as it is). + /// CJK text is on screen (whatever the UI language). Returns true when the Japanese font fetch + /// should start now: once, and never again after a failed load (the UI language stays as it is). pub fn text_needs_font(&mut self) -> bool { if self.status == CjkFontStatus::Unloaded { self.status = CjkFontStatus::Loading; @@ -929,8 +983,8 @@ fn arabic_font() -> Option> { .find_map(|path| try_read_font(&path).filter(|bytes| covers(bytes, 'ب'))) } -/// Web installs the bundled faces synchronously; Japanese is fetched by the shell on demand. -/// Preserve that downloaded face when preferences change later in the session. +/// Web installs the bundled faces synchronously; Japanese and Chinese are fetched by the shell +/// on demand. Preserve those downloaded faces when preferences change later in the session. #[cfg(target_arch = "wasm32")] pub fn load_system_fonts_in_background(_ctx: &egui::Context) {} @@ -948,8 +1002,12 @@ pub fn setup_fonts_without_waiting(ctx: &egui::Context, language: &str, ui_font: pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) { let previous = ctx.data(|d| d.get_temp::(installed_fonts_id())); let mut fonts = web_font_definitions(ui_font, previous); - if needs_cjk_font(language) { - promote_font_to_front(&mut fonts, CJK_FALLBACK); + if needs_cjk_font(language) + && let Some(primary) = fallback_order(language) + .into_iter() + .find(|name| [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK].contains(name) && fonts.font_data.contains_key(*name)) + { + promote_font_to_front(&mut fonts, primary); } install(ctx, fonts); } @@ -960,16 +1018,20 @@ fn web_font_definitions(ui_font: &str, previous: Option) -> Fon if let Some(font) = self::ui_font(ui_font) { fonts = with_ui_font(fonts, font); } - if let Some(data) = previous.and_then(|f| f.font_data.get(CJK_FALLBACK).cloned()) { - fonts.font_data.insert(CJK_FALLBACK.to_owned(), data); - for family in all_families() { - fonts.families.entry(family).or_default().push(CJK_FALLBACK.to_owned()); + if let Some(prev) = previous { + for name in [CJK_FALLBACK, CHINESE_FALLBACK] { + if let Some(data) = prev.font_data.get(name).cloned() { + fonts.font_data.insert(name.to_owned(), data); + for family in all_families() { + fonts.families.entry(family).or_default().push(name.to_owned()); + } + } } } with_symbol_fallback(fonts) } -/// Web starts with the bundled faces and symbol fallback; Japanese loads on demand. +/// Web starts with the bundled faces and symbol fallback; script fonts load on demand. #[cfg(target_arch = "wasm32")] pub fn setup_fonts(ctx: &egui::Context) { setup_fonts_for(ctx, "en", ""); @@ -1504,14 +1566,46 @@ mod tests { let mut loader = CjkFontLoader::default(); for lang in ["en", "de"] { let mut language = lang.to_owned(); - assert!(!loader.tick(&mut language), "no CJK font fetch for {lang}"); + assert!(!loader.tick(&mut language, false), "no CJK font fetch for {lang}"); } let mut language = "ja".to_owned(); - assert!(loader.tick(&mut language), "Japanese starts the fetch"); - assert!(!loader.tick(&mut language), "the fetch starts once"); + assert!(loader.tick(&mut language, false), "Japanese starts the fetch"); + assert!(!loader.tick(&mut language, false), "the fetch starts once"); loader.finished(true, &mut language); assert_eq!(loader.status(), CjkFontStatus::Installed); assert_eq!(language, "ja"); + assert!(!loader.tick(&mut language, true), "ready face needs no fetch"); + } + + #[test] + fn chinese_ui_fetches_again_after_japanese_is_installed() { + let mut loader = CjkFontLoader::default(); + let mut language = "ja".to_owned(); + assert!(loader.tick(&mut language, false)); + loader.finished(true, &mut language); + language = "zh".to_owned(); + assert!(loader.tick(&mut language, false), "Chinese needs its own face (#8894)"); + loader.finished(true, &mut language); + assert!(!loader.tick(&mut language, true)); + assert_eq!(web_script_font_kind("zh"), Some("zh")); + assert_eq!(web_script_font_kind("zh-TW"), Some("zh-TW")); + assert_eq!(web_script_font_kind("ja"), Some("ja")); + assert!(needs_chinese_font("zh")); + assert!(!needs_chinese_font("ja")); + } + + #[test] + fn web_chinese_subset_draws_simplified_settings() { + let bytes = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/craft-ui-sc/CraftUI-SC.otf")).to_vec(); + let ctx = test_context(); + assert!(install_web_script_fonts(&ctx, None, Some(bytes), "zh")); + let installed = ctx.data(|d| d.get_temp::(installed_fonts_id())).unwrap(); + for family in sans_families() { + assert_eq!(installed.families[&family].first().map(String::as_str), Some(CHINESE_FALLBACK)); + } + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + assert!(ctx.fonts_mut(|f| f.has_glyphs(&semibold(14.0), "设置"))); } #[test] @@ -1530,11 +1624,11 @@ mod tests { fn cjk_text_fetches_the_font_with_an_english_ui() { let mut loader = CjkFontLoader::default(); let mut language = "en".to_owned(); - assert!(!loader.tick(&mut language)); + assert!(!loader.tick(&mut language, false)); assert!(loader.watching_text()); assert!(loader.text_needs_font(), "CJK text starts the fetch"); assert!(!loader.text_needs_font(), "the fetch starts once"); - assert!(!loader.tick(&mut language), "no second fetch from the language"); + assert!(!loader.tick(&mut language, false), "no second fetch from the language"); assert!(!loader.watching_text()); loader.finished(false, &mut language); assert_eq!(loader.status(), CjkFontStatus::Failed); @@ -1545,9 +1639,9 @@ mod tests { assert!(loader.text_needs_font()); loader.finished(true, &mut language); assert_eq!(loader.status(), CjkFontStatus::Installed); - // Switching to Japanese afterwards needs no second fetch. + // Switching to Japanese afterwards needs no second fetch when the face is ready. let mut ja = "ja".to_owned(); - assert!(!loader.tick(&mut ja)); + assert!(!loader.tick(&mut ja, true)); } /// The painted-text check finds CJK in the frame's labels and nothing else. @@ -1571,16 +1665,16 @@ mod tests { fn failed_font_fetch_falls_back_for_the_session_without_changing_the_saved_language() { let mut loader = CjkFontLoader::default(); let mut language = "ja".to_owned(); - assert!(loader.tick(&mut language)); + assert!(loader.tick(&mut language, false)); loader.finished(false, &mut language); assert_eq!(language, "en", "this session shows English instead of tofu"); assert_eq!(loader.persisted_language(&language), "ja", "the saved preference stays Japanese"); - assert!(!loader.tick(&mut language)); + assert!(!loader.tick(&mut language, false)); assert_eq!(loader.persisted_language(&language), "ja"); // Choosing a language afterwards makes that the saved preference. language = "de".to_owned(); - assert!(!loader.tick(&mut language)); + assert!(!loader.tick(&mut language, false)); assert_eq!(loader.persisted_language(&language), "de"); } } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 192f6d29b5..2e6ddcfbcb 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -8,10 +8,9 @@ use std::sync::{Arc, Mutex}; use egui_wgpu::wgpu; pub use solidcraft_gpu::view::{ - AccessMap, BlockBuilder, BlockBytes, BlockTris, CLOUD_POINT_SIZE, DrawStats, DrawStatsHandle, Frame, GpuImage, GpuPoints, GpuScene, GpuTarget, - INSTANCE_SIZE, InstanceLook, InstancedMesh, LINE_SIZE, LOD_PX, MAX_PALETTE, PBR_SIZE, Palette, PaletteIndex, PlannedBody, Renderer, SHARED_SIZE, - SceneBlock, SceneSlot, SharedTris, TRI_SIZE, TriClass, UploadError, cloud_point, extend_runs, level_for, lifted_width, placement_scale, - runs_within, supersampled_size, + AccessMap, BlockBuilder, BlockBytes, BlockTris, DrawStats, DrawStatsHandle, Frame, GpuImage, GpuScene, GpuTarget, INSTANCE_SIZE, InstanceLook, + InstancedMesh, LINE_SIZE, LOD_PX, MAX_PALETTE, PBR_SIZE, Palette, PaletteIndex, PlannedBody, Renderer, SHARED_SIZE, SceneBlock, SceneSlot, + SharedTris, TRI_SIZE, TriClass, UploadError, extend_runs, level_for, lifted_width, placement_scale, runs_within, supersampled_size, }; /// A frame of the viewport as an egui paint callback. @@ -225,18 +224,44 @@ pub(crate) fn offscreen_backends() -> wgpu::Backends { }) } -/// A device of its own for an offscreen render or check (`None` without a GPU adapter), on -/// [`offscreen_backends`]. One test at a time starts its instance, adapter and device: starting an -/// instance scans the Vulkan drivers, and tests doing that side by side crashed the test process -/// inside the Vulkan loader (`loader_icd_scan`). Rendering on the devices still runs side by side. +/// Held while a test starts an instance, adapter or device: starting an instance scans the Vulkan +/// drivers, and tests doing that side by side crashed the test process inside the Vulkan loader +/// (`loader_icd_scan`). Rendering on the devices still runs side by side. #[cfg(all(test, not(target_arch = "wasm32")))] -pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { +fn offscreen_starting() -> std::sync::MutexGuard<'static, ()> { static STARTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _one_at_a_time = STARTING.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + STARTING.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The adapter an offscreen test opens under `policy` (`SOLIDCRAFT_GPU`, #9040), on +/// [`offscreen_backends`]; call it holding [`offscreen_starting`]. Under `auto` (the variable +/// unset) wgpu's default adapter, as before; under `hardware` or `software` the one +/// `solidcraft_gpu::select` picks for headless pictures of that tier, so `software` keeps the +/// tests off the hardware GPU; under `cpu`, as for pictures, none. `None` when none qualifies. +#[cfg(all(test, not(target_arch = "wasm32")))] +fn offscreen_adapter(policy: solidcraft_gpu::select::Policy) -> Option { + use solidcraft_gpu::select::Policy; let mut desc = solidcraft_gpu::instance_descriptor_from_env(); desc.backends = offscreen_backends(); let instance = wgpu::Instance::new(desc); - let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; + match policy { + Policy::Auto => pollster::block_on(instance.request_adapter(&Default::default())).ok(), + Policy::Hardware | Policy::Software => { + let found = pollster::block_on(instance.enumerate_adapters(offscreen_backends())); + solidcraft_gpu::select::pick_among(found, policy).map(|(adapter, _)| adapter) + } + Policy::Cpu => None, + } +} + +/// A device of its own for an offscreen render or check (`None` without a GPU adapter the +/// `SOLIDCRAFT_GPU` policy admits, or when the variable names no policy), on [`offscreen_backends`]. One test at a time starts its +/// instance, adapter and device ([`offscreen_starting`]). +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { + let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| eprintln!("no offscreen device: {e}")).ok()?; + let _one_at_a_time = offscreen_starting(); + let adapter = offscreen_adapter(policy)?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_features: solidcraft_gpu::depth_features(&adapter), required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), @@ -428,7 +453,7 @@ fn offscreen( clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis, analysis_colors, access: None, @@ -648,6 +673,33 @@ mod tests { assert!(chosen.contains(used), "the offscreen device runs on {used:?}, not on {chosen:?}"); } } + /// `SOLIDCRAFT_GPU` picks the offscreen tests' adapter as it picks headless pictures' (#9040): + /// `software` never a hardware GPU (it did, and the tests crashed one), `hardware` never a + /// software rasterizer, `cpu` none. A tier without an adapter here is skipped. Only adapters + /// are picked: no device is opened. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn offscreen_tests_pick_their_adapter_by_the_gpu_policy() { + use solidcraft_gpu::select::Policy; + let _one_at_a_time = super::offscreen_starting(); + assert!(super::offscreen_adapter(Policy::Cpu).is_none(), "cpu opens no adapter"); + for (policy, software) in [(Policy::Software, true), (Policy::Hardware, false)] { + match super::offscreen_adapter(policy) { + Some(adapter) => { + let info = adapter.get_info(); + assert_eq!( + info.device_type == egui_wgpu::wgpu::DeviceType::Cpu, + software, + "{policy:?} picked {} ({:?})", + info.name, + info.device_type + ); + } + None => eprintln!("skipping {policy:?}: no adapter of that tier"), + } + } + } + /// A device the driver loses (#4172): its lost callback marks it, the painter's probe sees it /// before the next frame's uploads, those uploads no longer reach the device (they panicked /// inside wgpu when they were the first to meet the loss), and pictures (thumbnails of saves) diff --git a/crates/ui-egui/src/help.rs b/crates/ui-egui/src/help.rs index d7b5e653b5..fbef19384a 100644 --- a/crates/ui-egui/src/help.rs +++ b/crates/ui-egui/src/help.rs @@ -28,25 +28,26 @@ pub struct HelpState { /// One line per fact, for bug reports. pub fn diagnostics(app: &SolveApp) -> String { let renderer = if app.viewport.gpu.is_some() { "GPU" } else { "CPU" }; + let count = |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many }); format!( - "SolidCraft {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({} features, {} bodies)", + "SolidCraft {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({}, {})", std::env::consts::OS, std::env::consts::ARCH, app.help.gpu.as_ref().map(|g| format!(", {g}")).unwrap_or_default(), if app.custom_titlebar { "custom title bar" } else { "system title bar" }, app.session.doc.name, - app.session.doc.features.len(), - app.session.world_state().bodies.len(), + count(app.session.doc.features.len(), "feature", "features"), + count(app.session.world_state().bodies.len(), "body", "bodies"), ) } /// Whether a Help menu item can run now: behind an open bug report, the items that open a /// window would cover it (#7188). Report an Issue… shows the report. pub fn available(app: &SolveApp, item: &str) -> bool { - app.issue_report.draft.is_none() || !matches!(item, "about" | "shortcuts" | "messages" | "crashes" | "connect") + app.issue_report.draft.is_none() || !matches!(item, "about" | "shortcuts" | "messages" | "crashes" | "connect" | "updates") } -/// Run a Help menu item: `docs`, `about`, `shortcuts`, `messages`, `report`, `crashes`, `connect`, `install_cli` (macOS). +/// Run a Help menu item: `docs`, `about`, `updates`, `shortcuts`, `messages`, `report`, `crashes`, `connect`, `install_cli` (macOS). pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) { app.help.menu = false; if !available(app, item) { @@ -55,6 +56,7 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) { match item { "docs" => ctx.open_url(egui::OpenUrl::new_tab(DOCS_URL)), "about" => app.help.about = true, + "updates" => crate::update::check_now(app, ctx), "shortcuts" => { app.keymap.open = true; app.keymap.read_only = true; @@ -130,6 +132,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) { for (id, label, icon) in [ ("docs", "Documentation", "library"), ("about", "About SolidCraft", "help"), + ("updates", crate::i18n::tr("update.check_for_updates"), "update"), ("shortcuts", "Keyboard Shortcuts", "list"), ("messages", "Messages…", "list"), ("report", "Report an Issue…", "bug_report"), @@ -227,4 +230,15 @@ mod tests { let d = diagnostics(&app); assert!(d.contains(VERSION) && d.contains(std::env::consts::OS) && d.contains("Renderer: CPU"), "{d}"); } + + /// One feature and one body are counted in the singular (#9085), more in the plural. + #[test] + fn diagnostics_count_one_feature_and_one_body_in_the_singular() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + assert!(diagnostics(&app).ends_with("(0 features, 0 bodies)"), "{}", diagnostics(&app)); + app.session.execute("solid.box", &serde_json::json!({"length": 10, "width": 10, "height": 10})).unwrap(); + assert!(diagnostics(&app).ends_with("(1 feature, 1 body)"), "{}", diagnostics(&app)); + app.session.execute("solid.box", &serde_json::json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]})).unwrap(); + assert!(diagnostics(&app).ends_with("(2 features, 2 bodies)"), "{}", diagnostics(&app)); + } } diff --git a/crates/ui-egui/src/history_off_thread_tests.rs b/crates/ui-egui/src/history_off_thread_tests.rs index 8e898d2e97..702b72bce4 100644 --- a/crates/ui-egui/src/history_off_thread_tests.rs +++ b/crates/ui-egui/src/history_off_thread_tests.rs @@ -682,6 +682,43 @@ 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); } +/// 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] +fn a_reading_on_a_copy_keeps_the_requests_param_check() { + let (mut h, tx) = agent_harness(); + let measure = ask(&tx, "engine.execute", json!({"command": "inspect.measure", "params": {}, "param_check": "strict"})); + h.frames(1); + assert_eq!(h.app.control_answering.len(), 1, "read a copy"); + let r = reply_to(&mut h, &measure); + assert_eq!((&r["ok"], &r["param_check"]), (&json!(true), &json!("strict")), "{r}"); + let loud = ask(&tx, "engine.execute", json!({"command": "inspect.measure", "params": {}, "param_check": "loud"})); + let r = reply_to(&mut h, &loud); + assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.contains("`param_check` must be off, warn or strict")), "{r}"); + // A design change computed ahead off the window thread runs here at the request's level. + let features = h.app.session.doc.features.len(); + let misspelt = |level: &str| { + let mut call = second_hole(); + call["params"]["operaton"] = json!("cut"); + call["param_check"] = json!(level); + call + }; + let strict = ask(&tx, "engine.execute", misspelt("strict")); + h.frames(1); + assert!(h.app.agent_computing().is_some(), "computed ahead off the window thread"); + let r = reply_to(&mut h, &strict); + assert_eq!((&r["ok"], &r["param_check"]), (&json!(false), &json!("strict")), "{r}"); + assert_eq!(h.app.session.doc.features.len(), features, "refused unrun"); + let warn = ask(&tx, "engine.execute", misspelt("warn")); + let r = reply_to(&mut h, &warn); + assert_eq!( + (&r["param_check"], &r["result"]["param_warnings"]), + (&json!("warn"), &json!(["unknown key `operaton`; did you mean `operation`?"])), + "{r}" + ); + assert_eq!(h.app.session.param_check, solidcraft_engine::schema::ParamCheck::Off, "the app's own level again"); +} + /// A clearance or interference check of every pair of bodies reads a copy off the window thread /// too: on an assembly of 150 bodies it computed for minutes with the window not responding. The /// answers are the ones running it here gives. diff --git a/crates/ui-egui/src/hole_direction_ui_tests.rs b/crates/ui-egui/src/hole_direction_ui_tests.rs index 98b137fa27..5aedf8562d 100644 --- a/crates/ui-egui/src/hole_direction_ui_tests.rs +++ b/crates/ui-egui/src/hole_direction_ui_tests.rs @@ -124,12 +124,17 @@ fn flip_drills_holes_at_sketch_points_the_other_way() { assert!(h.app.dialog.is_none(), "OK closed the dialog: {:?}", h.app.dialog.as_ref().map(|d| &d.error)); assert!(!solid_at(&h, Vec3::new(10.0, 10.0, 2.5)), "flipped: the hole comes up from the bottom"); assert!(solid_at(&h, Vec3::new(10.0, 10.0, 17.5)), "and not down from the top"); + assert!(!solidcraft_engine::view::sketch_shown(&h.app.session, sketch), "the Hole used its points sketch"); // Edit Feature shows Flip on, and OK keeps it. let id = h.app.session.doc.features.last().unwrap().id; h.step(&json!({"call": "ui.editFeature", "params": {"feature": id}})).unwrap(); h.frames(4); let d = h.app.dialog.as_ref().expect("the hole dialog"); assert_eq!(d.extra.get("flip"), Some(&json!(true))); + assert_eq!(h.app.session.feature_edit_sketch, Some(sketch), "editing shows its points to pick"); + assert!(solidcraft_engine::view::sketch_shown(&h.app.session, sketch), "editing shows its points to pick"); h.step(&json!({"key": "Enter"})).unwrap(); assert!(!solid_at(&h, Vec3::new(10.0, 10.0, 2.5)) && solid_at(&h, Vec3::new(10.0, 10.0, 17.5))); + assert_eq!(h.app.session.feature_edit_sketch, None); + assert!(!solidcraft_engine::view::sketch_shown(&h.app.session, sketch)); } diff --git a/crates/ui-egui/src/hole_place_ui_tests.rs b/crates/ui-egui/src/hole_place_ui_tests.rs index edf780914a..771842b8c6 100644 --- a/crates/ui-egui/src/hole_place_ui_tests.rs +++ b/crates/ui-egui/src/hole_place_ui_tests.rs @@ -334,7 +334,7 @@ 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"})); let page = rect_of("prefs:page_body").unwrap(); - for label in ["Snap to face and edge centres", "Move holes by clicking or dragging"] { + for label in ["Snap to face and edge centers", "Move holes by clicking or dragging"] { let mut point = None; for _ in 0..12 { point = h.output.accesskit_update.as_ref().and_then(|tree| { diff --git a/crates/ui-egui/src/hole_refs.rs b/crates/ui-egui/src/hole_refs.rs index 768a2348a5..8cd51464e6 100644 --- a/crates/ui-egui/src/hole_refs.rs +++ b/crates/ui-egui/src/hole_refs.rs @@ -245,10 +245,20 @@ pub(crate) fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, names: let Some(k) = d.inputs.get(i).filter(|inp| !inp.items.is_empty()).and_then(|inp| REFERENCES.iter().position(|l| *l == inp.label)) else { continue; }; + let mut keyboard = None; if let Some(refs) = refs_mut(d) && let (Some(label), Some(value)) = (DISTANCES.get(k), refs.distance.get_mut(k)) { enter |= crate::ui_kit::expr_row(ui, label, value); + // Without value boxes in the view (#9479), a newly picked edge's distance takes the + // keyboard here, as its box would. + if refs.focus == Some(k) && !crate::canvas::boxes_shown(ui.ctx()) { + refs.focus = None; + keyboard = crate::ui_kit::last_value_field(ui.ctx()); + } + } + if let Some(id) = keyboard { + d.give_keyboard(id); } } sync(s, d); @@ -342,7 +352,8 @@ pub(crate) fn canvas(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, } dimension(painter, a, b, t.dimension); let (Some(label), Some(refs)) = (DISTANCES.get(*k), refs_mut(d)) else { continue }; - let focus = refs.focus == Some(*k); + // Without boxes in the view, the distance's row in the command window takes it (#9479). + let focus = refs.focus == Some(*k) && crate::canvas::boxes_shown(ui.ctx()); if focus { refs.focus = None; took = true; diff --git a/crates/ui-egui/src/hole_refs_ui_tests.rs b/crates/ui-egui/src/hole_refs_ui_tests.rs index bdc4103057..12d3d64e42 100644 --- a/crates/ui-egui/src/hole_refs_ui_tests.rs +++ b/crates/ui-egui/src/hole_refs_ui_tests.rs @@ -162,6 +162,28 @@ fn a_number_typed_after_an_edge_pick_sets_its_distance() { assert_eq!(diameter, "5 mm", "the diameter is left as it was"); } +/// Without value boxes in the view (#9479), the distance's row in the command window takes the +/// number typed after an edge pick, as its box would. +#[test] +fn without_value_boxes_a_number_typed_after_an_edge_pick_sets_its_distance() { + let mut h = plate(); + h.app.preferences.value_boxes_in_view = false; + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = screen(&mut h, Vec3::new(30.0, 25.0, 10.0)); + h.step(&json!({"click": at})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Reference 1"}})).unwrap(); + let left = screen(&mut h, Vec3::new(0.0, 20.0, 10.0)); + h.step(&json!({"click": left})).unwrap(); + h.frames(2); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new(("sc_hole_ref_area", 0usize)))).is_none(), "a distance box in the view"); + h.step(&json!({"text": "7 mm"})).unwrap(); + h.frames(2); + assert_eq!(distances(&h)[0], "7 mm"); + assert!((place(&h).x - 7.0).abs() < 1e-6, "the hole is 7 mm from the left edge: {:?}", place(&h)); + let Some(Kind::Hole { diameter, .. }) = h.app.dialog.as_ref().map(|d| &d.kind) else { panic!("the hole dialog") }; + assert_eq!(diameter, "5 mm", "the diameter is left as it was"); +} + /// The open Cylinder dialog's reference distances and where it stands. fn cylinder_dialog(h: &Harness) -> ([String; 2], Vec3) { match h.app.dialog.as_ref().map(|d| &d.kind) { diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9a8f81b53b..64c0b7d909 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -54,6 +54,10 @@ pub struct HomeState { sample_rx: Option>, #[cfg(not(target_arch = "wasm32"))] remembering: Option>, + /// Watches the designs the picture threads open and build, and stops them when the app + /// closes ([`finish_pictures`]): closing must not wait for every cold recent file (#8398). + #[cfg(not(target_arch = "wasm32"))] + stop: std::sync::Arc, /// A sample picked before its design was ready. wanted: Option, /// Whether each recent file is there, found off the window thread (a stat of a slow or @@ -214,8 +218,12 @@ fn remember_at(s: &Session, key: &str, #[cfg(not(target_arch = "wasm32"))] ident /// Waits up to `within` for the threads drawing Home pictures, and drops what they made: the /// window is going, and a picture still on the GPU as the process exits can crash the driver. +/// They are told to stop first: the design being built stops at its next feature and the cold +/// recent files after it are left alone, so closing waits for one picture at most, not for every +/// recent design to be imported and rebuilt (#8398). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn finish_pictures(h: &mut HomeState, within: std::time::Duration) { + h.stop.cancel(); let end = std::time::Instant::now() + within; for rx in [h.rx.take(), h.sample_rx.take(), h.remembering.take()].into_iter().flatten() { // Each thread sends what it made and ends; its channel closes when it has. @@ -296,8 +304,28 @@ fn sample_picture(i: usize) -> Option { decode_thumbnail(SAMPLE_PICTURES.get(i)?.to_vec()) } -/// Build what a key stands for (runs off the UI thread where threads exist). +/// Build what a key stands for (on the web, which has no threads; elsewhere [`make_watched`]). +#[cfg(any(test, target_arch = "wasm32"))] fn make(key: &str) -> Made { + make_unless(key, &|| false) +} + +/// [`make`] on a picture thread watched by `stop` ([`HomeState::stop`]): once stopped, the +/// design is built no further and no picture is drawn or cached. +#[cfg(not(target_arch = "wasm32"))] +fn make_watched(key: &str, stop: &std::sync::Arc) -> Made { + if stop.is_cancelled() { + return (key.to_string(), None, None); + } + let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + solidcraft_engine::doc::progress::watch(stop.clone(), || make_unless(key, &|| stop.is_cancelled())) + })); + made.unwrap_or_else(|_| (key.to_string(), None, None)) +} + +/// [`make`], with no picture once `stopped` says so: a build stopped part way is not the design, +/// and its picture must not be cached as the design's. +fn make_unless(key: &str, stopped: &dyn Fn() -> bool) -> Made { let sample = key.strip_prefix("sample:").and_then(|i| i.parse::().ok()); if sample.is_none() && let Some(img) = picture(key) @@ -315,7 +343,7 @@ fn make(key: &str) -> Made { }), None => s.execute("doc.open", &serde_json::json!({ "path": key })).is_ok(), }; - if !built { + if !built || stopped() { return (key.to_string(), None, None); } if sample.is_some() { @@ -354,12 +382,16 @@ fn start_jobs(h: &mut HomeState) { } let mut keys = std::mem::take(&mut h.queued); let (tx, rx) = std::sync::mpsc::channel(); + let stop = h.stop.clone(); let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || { // Prefer local PNGs; even the metadata checks belong off the window thread. keys.sort_by_key(|k| !cache_path(k).is_some_and(|p| p.exists())); let Some(cold) = read_pictures(keys, &tx) else { return }; for k in cold { - let made = std::panic::catch_unwind(|| make(&k)).unwrap_or((k, None, None)); + if stop.is_cancelled() { + return; + } + let made = make_watched(&k, &stop); if tx.send(made).is_err() { return; } @@ -377,11 +409,11 @@ fn start_sample(h: &mut HomeState) { return; } let (tx, rx) = std::sync::mpsc::channel(); + let stop = h.stop.clone(); if std::thread::Builder::new() .name("selected-sample".into()) .spawn(move || { - let key = format!("sample:{i}"); - let made = std::panic::catch_unwind(|| make(&key)).unwrap_or((key, None, None)); + let made = make_watched(&format!("sample:{i}"), &stop); let _ = tx.send(made); }) .is_ok() @@ -828,7 +860,8 @@ fn start_content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option) -> String { } } +/// Kind column for a recent file: Design for `.sc`/legacy, else STEP, STL, … (#9081). +fn recent_kind(app: &SolveApp, path: &str) -> String { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or(""); + if solidcraft_engine::io::is_design_ext(ext) || ext.eq_ignore_ascii_case("json") { + return app.tr("home.design_workspace").to_string(); + } + if solidcraft_engine::io::is_drawing_path(path) { + return app.tr_label("Drawing").to_string(); + } + let label = match ext.to_ascii_lowercase().as_str() { + "step" | "stp" => "STEP", + "igs" | "iges" => "IGES", + "stl" => "STL", + "3mf" => "3MF", + "obj" => "OBJ", + "glb" | "gltf" => "glTF", + "fcstd" => "FreeCAD", + "3dm" => "Rhino", + other if !other.is_empty() => return other.to_ascii_uppercase(), + _ => return app.tr("home.design_workspace").to_string(), + }; + label.to_string() +} + fn recent_time(app: &SolveApp, path: &str) -> String { let Some(time) = app.home.opened_at.get(path) else { return String::new() }; let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time); @@ -1080,6 +1138,31 @@ mod tests { assert_eq!(abbreviated_path("/disk/design.solidcraft", None), "/disk/design.solidcraft"); } + #[test] + fn recent_kind_names_imports_by_extension() { + let app = crate::test_support::sample_app(); + assert_eq!(recent_kind(&app, "/tmp/plate.sc"), "Design"); + assert_eq!(recent_kind(&app, "/tmp/plate.solidcraft"), "Design"); + assert_eq!(recent_kind(&app, "/tmp/plate.step"), "STEP"); + assert_eq!(recent_kind(&app, "/tmp/plate.STL"), "STL"); + assert_eq!(recent_kind(&app, "/tmp/plate.3mf"), "3MF"); + assert_eq!(recent_kind(&app, "/tmp/plate.obj"), "OBJ"); + } + + #[test] + fn middle_ellipsis_keeps_both_ends_of_a_path() { + let ctx = crate::fonts::test_context(); + let mut shown = String::new(); + crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| { + let font = egui::FontId::monospace(12.0); + let full = "~/Projects/exports/out/plate.step"; + shown = crate::ui_kit::ellipsize_middle(ui, full, font, 80.0); + }); + assert!(shown.contains('…'), "{shown}"); + assert!(shown.ends_with("plate.step") || shown.ends_with(".step"), "{shown}"); + assert!(shown.starts_with('~') || shown.starts_with("~/P"), "{shown}"); + } + #[test] fn recent_times_use_compact_labels_and_calendar_dates() { let app = crate::test_support::sample_app(); @@ -1273,6 +1356,46 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// Closing the app with cold recent files still being imported for their pictures stops + /// them: it does not wait for each to be built, and nothing built part way is cached (#8398). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn closing_stops_the_cold_recent_pictures_instead_of_waiting_for_them() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let mut part = Session::default(); + part.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); + let keys: Vec = (0..4) + .map(|n| { + let path = std::env::temp_dir().join(format!("solidcraft-closing-cold-{}-{nonce}-{n}.step", std::process::id())); + let key = path.to_string_lossy().into_owned(); + part.execute("file.export", &serde_json::json!({"path":key})).unwrap(); + key + }) + .collect(); + let mut h = HomeState::default(); + // The first cold design waits before its first feature until stopped: still being + // built whatever the machine's speed when the app closes. + h.stop.hold_at(0); + h.queued = keys.clone(); + start_jobs(&mut h); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.stop.features().1 == 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(h.stop.features().1 > 0, "the first cold recent is being built"); + let rx = h.rx.take().unwrap(); + assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)), "it is still being built"); + h.rx = Some(rx); + let began = std::time::Instant::now(); + finish_pictures(&mut h, std::time::Duration::from_secs(60)); + assert!(began.elapsed() < std::time::Duration::from_secs(30), "closing waited {:?}", began.elapsed()); + assert!(h.rx.is_none()); + for key in keys { + assert!(!cache_path(&key).unwrap().exists(), "no picture of a stopped build is cached"); + std::fs::remove_file(key).unwrap(); + } + } + #[test] fn first_home_frame_shows_placeholders_and_the_sample_pictures_arrive_without_building_any_model() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1c7d3003a8..e4351e8093 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -64,6 +64,7 @@ static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env! /// English in UK spelling: only the messages whose spelling differs; the rest are English's. static ENGLISH_UK: Catalog = Catalog::new("en-GB", "English (UK)", include_str!(concat!(env!("OUT_DIR"), "/locales/en-GB.json"))); static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("OUT_DIR"), "/locales/es.json"))); +static FINNISH: Catalog = Catalog::new("fi", "Suomi", include_str!(concat!(env!("OUT_DIR"), "/locales/fi.json"))); static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("OUT_DIR"), "/locales/fr.json"))); static GALICIAN: Catalog = Catalog::new("gl", "Galego", include_str!(concat!(env!("OUT_DIR"), "/locales/gl.json"))); static HEBREW: Catalog = Catalog::new("he", "עברית", include_str!(concat!(env!("OUT_DIR"), "/locales/he.json"))); @@ -99,6 +100,7 @@ pub const LOCALES: &[&Catalog] = &[ &GERMAN, &ENGLISH_UK, &SPANISH, + &FINNISH, &FRENCH, &GALICIAN, &HEBREW, @@ -348,6 +350,18 @@ mod tests { } } + #[test] + fn construction_operation_names_exist_in_every_supported_catalog() { + let family = crate::sketch_families::family("sketch.construction_line").unwrap(); + for catalog in LOCALES { + assert!(catalog.messages().get("ui.construction.ribbon_label").is_some_and(|v| !v.trim().is_empty()), "{}: ribbon label", catalog.id); + for id in family.commands { + let key = format!("cmd.{id}"); + assert!(catalog.messages().get(&key).is_some_and(|v| !v.trim().is_empty()), "{}: {key}", catalog.id); + } + } + } + #[test] fn alias_resolution_regression() { static LEGACY: Catalog = Catalog::new("test-legacy", "Test", r#"{"Pitch":"legacy pitch"}"#); @@ -1142,6 +1156,18 @@ mod tests { assert_eq!(gl.label("NonExistentKey"), "NonExistentKey"); } + #[test] + fn finnish_catalog_is_registered_and_translates_keys() { + let fi = Locale::for_language("fi"); + assert_eq!(fi.name(), "Suomi"); + assert!(is_supported("fi")); + assert_eq!(fi.text("ui.preferences"), "Asetukset"); + assert_eq!(fi.format("document.save_changes", &[("name", "My {design}")]), "Tallennetaanko muutokset tiedostoon My {design}?"); + assert_eq!(fi.format("dialog.selected_count", &[("count", "2")]), "↖ 2 valittu"); + assert_eq!(fi.label("Fillet"), "Pyöristys"); + assert_eq!(fi.label("NonExistentKey"), "NonExistentKey"); + } + #[test] fn ukrainian_catalog_translates_keys_labels_and_browser_patterns() { let uk = Locale::for_language("uk"); diff --git a/crates/ui-egui/src/i18n_menu_tests.rs b/crates/ui-egui/src/i18n_menu_tests.rs index 247e7ec2b2..4845ec4563 100644 --- a/crates/ui-egui/src/i18n_menu_tests.rs +++ b/crates/ui-egui/src/i18n_menu_tests.rs @@ -42,6 +42,8 @@ fn take(app: &SolveApp, out: &mut BTreeSet) { Target::Workspace, Target::NamedViews, Target::InsertDesign, + Target::VisualStyle, + Target::ViewTool, ] { take_items(out, &context_menu::items(app, &target)); } diff --git a/crates/ui-egui/src/i18n_painted_tests.rs b/crates/ui-egui/src/i18n_painted_tests.rs index 8d4ee6f9fd..2c4682cd63 100644 --- a/crates/ui-egui/src/i18n_painted_tests.rs +++ b/crates/ui-egui/src/i18n_painted_tests.rs @@ -67,11 +67,20 @@ fn design_names(app: &SolveApp) -> BTreeSet { /// in `language`. (When one entry with the text is still English there, English is right.) /// `names` are the design's own names, which are not UI text, except the default names the /// language translates (a new design's `Untitled`, `Sketch1`): those must show translated. +/// A text the language gives another message is its own word, not English: Romanian's Aspect +/// (Appearance) is also the English Aspect of the Render Frame stage. fn english_leaks(language: &str, texts: &BTreeSet, english: &BTreeMap>, names: &BTreeSet) -> Vec { let locale = crate::i18n::Locale::for_language(language); + let own: BTreeSet<&str> = english + .iter() + .flat_map(|(text, ids)| ids.iter().map(move |id| (text.as_str(), locale.text(id)))) + .filter(|(text, shown)| text != shown) + .map(|(_, shown)| shown) + .collect(); texts .iter() .filter(|text| !names.contains(*text) || locale.given_name(text) != text.as_str()) + .filter(|text| !own.contains(text.as_str())) .filter(|text| english.get(*text).is_some_and(|ids| ids.iter().all(|id| locale.text(id) != text.as_str()))) .cloned() .collect() @@ -153,7 +162,8 @@ fn no_english_is_painted_where_the_language_has_a_translation() { } /// The sweep can see a leak: English painted by a plain painter call is reported, the same text -/// in the language is not, and English that the language leaves untranslated is not either. +/// in the language is not, English that the language leaves untranslated is not either, and nor +/// is a word the language gives another message. #[test] fn the_sweep_reports_english_only_where_a_translation_exists() { let english = english_ids(); @@ -165,4 +175,10 @@ fn the_sweep_reports_english_only_where_a_translation_exists() { // "Fillet" here is a feature someone named so: a design name, not a label. let names: BTreeSet = ["Fillet".to_string()].into(); assert_eq!(english_leaks("ja", &texts, &english, &names), vec!["Center Rectangle".to_string()]); + // A word the language gives another message is its own, though English has a message with + // the same letters: Romanian's Aspect is Appearance, English's Aspect is the Frame stage's. + let ro = crate::i18n::Locale::for_language("ro"); + assert_eq!(ro.text("cmd.appearance_assign"), "Aspect"); + assert_ne!(ro.text("render.frame_aspect"), "Aspect"); + assert!(english_leaks("ro", &["Aspect".to_string()].into(), &english, &BTreeSet::new()).is_empty()); } diff --git a/crates/ui-egui/src/icon_svg.rs b/crates/ui-egui/src/icon_svg.rs index 7a75fb3322..ec1a60837a 100644 --- a/crates/ui-egui/src/icon_svg.rs +++ b/crates/ui-egui/src/icon_svg.rs @@ -829,7 +829,7 @@ mod tests { Some("surface") } else if c.panel == "CONSTRAINTS" { Some("constraint") - } else if matches!(c.id, "sketch.construction" | "sketch.centerline") { + } else if matches!(c.id, "sketch.construction" | "sketch.centerline") || c.id.starts_with("sketch.construction_line") { Some("construction") } else if c.id.starts_with("sketch.") && matches!(c.panel, "CREATE" | "MODIFY" | "PATTERN" | "FINISH SKETCH") { Some("sketch") @@ -942,6 +942,7 @@ mod tests { #[test] fn sketch_construction_modes_have_distinct_icons() { let families: &[&[&str]] = &[ + crate::sketch_families::family("sketch.construction_line").unwrap().commands, &["sketch.polygon.inscribed", "sketch.polygon.circumscribed", "sketch.polygon.edge"], &["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"], &["sketch.slot.arc_three_point", "sketch.slot.arc_center"], diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 220c20ef74..990236e939 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -574,7 +574,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { label: app.tr_label("Export…"), icon: Some("export_tray"), visible: true, - tooltip: Some(app.tr_label("Keys, S box favourites and the toolbar, as a file")), + tooltip: Some(app.tr_label("Keys, S box favorites and the toolbar, as a file")), }, ]; let (filter, toolbar_action) = ui_kit::search_actions_row( diff --git a/crates/ui-egui/src/length_input.rs b/crates/ui-egui/src/length_input.rs index 1fbd7e019a..552fd93eef 100644 --- a/crates/ui-egui/src/length_input.rs +++ b/crates/ui-egui/src/length_input.rs @@ -40,25 +40,7 @@ pub(crate) fn expression(doc: &Document, text: &str, kind: Kind) -> String { } let normalized = doc.with_design_unit(text, kind); if kind == Kind::Length && unit(doc) != "mm" && normalized == text && doc.eval(text, Kind::Unitless).is_ok() { - // A decimal comma is numeric only outside parentheses. Preserve function-argument - // commas while moving the expression inside the unit multiplication's parentheses. - let mut depth = 0usize; - let grouped: String = text - .chars() - .map(|c| match c { - '(' => { - depth = depth.saturating_add(1); - c - } - ')' => { - depth = depth.saturating_sub(1); - c - } - ',' if depth == 0 => '.', - _ => c, - }) - .collect(); - format!("({grouped}) * 1 {}", unit(doc)) + format!("({text}) * 1 {}", unit(doc)) } else { normalized } @@ -240,3 +222,18 @@ pub(crate) fn extra(extra: &mut serde_json::Map, mut } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_decimal_comma_survives_the_design_unit() { + // The unit wraps the text in parentheses, where a comma stays decimal (#9408). + let doc = Document { units: "in".into(), ..Document::default() }; + for (text, inches) in [("2,5", 2.5), ("(2,5)", 2.5), ("-(10-0,5)/2", -4.75), ("2,5 + max(1,2)", 4.5)] { + let mm = eval(&doc, text, Kind::Length).unwrap(); + assert!((mm - inches * 25.4).abs() < 1e-9, "{text}: {mm}"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 776ab4cb37..a5082af89f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -194,6 +194,7 @@ pub mod op_auto; pub mod open_elsewhere; pub(crate) mod open_meshing; pub mod params_dialog; +pub mod personal_sheet_rules; pub(crate) mod pick_index; pub(crate) mod place_gizmo; pub(crate) mod plugins; @@ -207,6 +208,10 @@ pub mod preview; mod primitive_placement; #[cfg(test)] mod primitive_units_tests; +mod print_face_ui; +pub(crate) mod quick_measure; +#[cfg(test)] +mod quick_measure_tests; pub mod recovery_ui; pub mod ref_images; mod render_ui; @@ -238,6 +243,7 @@ mod sketch3d_frame_ui_tests; pub(crate) mod sketch_attachment_ui; #[cfg(test)] mod sketch_attachment_ui_tests; +mod sketch_construction; pub mod sketch_dims; pub(crate) mod sketch_display; #[cfg(test)] @@ -256,6 +262,7 @@ mod sketch_modify_ui_tests; mod sketch_moving_click_tests; pub mod sketch_palette; mod sketch_pattern_tree; +pub mod sketch_report; #[cfg(test)] mod sketch_side_ui_tests; mod sketch_state; @@ -298,6 +305,7 @@ mod view_confirm; pub mod view_pick; pub mod viewport; mod viewport_layout; +mod viewport_status; mod viewport_status_fixture; pub mod workspace; @@ -360,6 +368,18 @@ pub struct UiState { pub tool_cursor: bool, } +impl UiState { + fn pick_mode(&self) -> &'static str { + if self.pick_components { + "component" + } else if self.pick_bodies { + "body" + } else { + "face" + } + } +} + impl Default for UiState { fn default() -> Self { UiState { @@ -405,6 +425,11 @@ pub struct Services { pub graphics_path: Option, /// Linux: the window system this run uses, `wayland` or `x11` (Preferences shows it, #3083). pub window_system: Option<&'static str>, + /// The desktop's dark (`Some(true)`) or light preference as the host reads it, for Theme: + /// System where the window toolkit does not report it (Linux desktops, #7944). Called every + /// frame, so it only reads a value kept up to date elsewhere. None, or `None` from it: the + /// window toolkit's theme. + pub desktop_dark: Option Option>>, /// Adapter names and descriptive labels from the desktop renderer's chosen backends. pub graphics_adapters: Vec<(String, String)>, /// HTTPS GET for the update check (`update`); None: never check (browser, tests). @@ -451,7 +476,16 @@ pub struct PreSketchComponent { pub opened_in: u64, } +/// Which late UI handler has first refusal of this frame's Escape. Reserving input +/// is distinct from having performed cancellation (`esc_handled`). +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum EscapeOwner { + Dialog, + Overlay, +} + pub struct SolveApp { + pub(crate) viewport_status: ui_kit::StatusPresentation, pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture, /// Compact browser embed: retain model panels and joint controls without application chrome. pub embedded: bool, @@ -488,6 +522,8 @@ pub struct SolveApp { pub params: Option, /// The Design Properties window, when open. pub design_properties: Option, + /// The Sketch report window (`sketch.inspect` from the ribbon), when open. + pub sketch_report: Option, /// The Bill of Materials window, when open. pub bom: Option, material_card_review: Option, @@ -573,6 +609,7 @@ pub struct SolveApp { pub foreign_copy: 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, /// When Esc was last pressed (input time): a second press soon after finishes the sketch. pub last_escape: Option, pub status: Option<(String, f64, bool)>, @@ -591,6 +628,10 @@ pub struct SolveApp { pub network_prompt: Option, /// Designs left by crashed apps, offered for recovery. pub recoverable: Vec, + /// Discard all was clicked in the recovery window: its confirmation is showing (#9299). + pub recovery_discard_all: bool, + /// The selection's measurement in the view's corner (#7000). + pub(crate) quick_measure: quick_measure::QuickMeasure, /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). pub stand_up: Option>, /// The placement of the component that file came in as: the offer turns it. @@ -657,6 +698,8 @@ pub struct SolveApp { styled: bool, fitted: bool, pending_fit: Option<(Camera, solidcraft_engine::geom::Aabb3, 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. pub flat_view: dialogs_sheet::FlatView, /// The start page. @@ -704,6 +747,7 @@ impl SolveApp { #[cfg(not(target_arch = "wasm32"))] solidcraft_engine::slicer_files::clean_old(); let mut app = SolveApp { + viewport_status: Default::default(), viewport_status_fixture: Default::default(), session, ui: UiState::default(), @@ -725,6 +769,7 @@ impl SolveApp { dialog: None, params: None, design_properties: None, + sketch_report: None, bom: None, material_card_review: None, menu: context_menu::MenuState::default(), @@ -767,6 +812,7 @@ impl SolveApp { own_clipboard_stamp: 0, foreign_copy: None, esc_handled: false, + escape_owner: None, last_escape: None, status: None, status_undo: false, @@ -777,6 +823,8 @@ impl SolveApp { disk_prompt: false, network_prompt: None, recoverable: Vec::new(), + recovery_discard_all: false, + quick_measure: Default::default(), stand_up: None, stand_up_occurrence: None, stand_up_reason: false, @@ -812,6 +860,7 @@ impl SolveApp { styled: false, fitted: false, pending_fit: None, + fit_waits: 0, flat_view: Default::default(), embedded: false, home: home::HomeState::default(), @@ -1408,18 +1457,35 @@ impl SolveApp { }); } + /// A command that needs one choice from a short list: its menu under the button that started it + /// (a second click closes it), or in the middle of the viewport when started without one + /// (command search, a shortcut, `ui.start`). + fn open_choice_menu(&mut self, opener: Option, target: context_menu::Target) { + if let Some(rect) = opener { + context_menu::toggle_for(self, rect, rect.left_bottom(), target); + } else { + let at = self.viewport.rect.map(|rect| rect.center()).unwrap_or(egui::pos2(300.0, 200.0)); + context_menu::open_for(self, at, target); + } + } + /// Start an interactive command with its menu anchored below the invoking button. pub(crate) fn start_at(&mut self, id: &str, opener: Option) { - self.start_with_variant_memory(id, true, opener); + self.start_with_variant_memory(id, true, opener, false); } /// A dedicated toolbar button starts its exact tool without replacing the sketch family's /// main button. Choosing a variant from a menu still uses `start` and remembers that choice. pub(crate) fn start_pinned(&mut self, id: &str, opener: Option) { - self.start_with_variant_memory(id, false, opener); + self.start_with_variant_memory(id, false, opener, false); } - fn start_with_variant_memory(&mut self, id: &str, remember_variant: bool, opener: Option) { + /// Existing Section editor uses the normal command-start lifecycle, with explicit edit intent. + pub(crate) fn start_section_edit(&mut self) { + self.start_with_variant_memory("inspect.section", true, None, true); + } + + fn start_with_variant_memory(&mut self, id: &str, remember_variant: bool, opener: Option, section_edit: bool) { self.tree.clear_folder_pick(); if id == "timeline.play" { if let Err(why) = self.run(id, json!({})) { @@ -1454,6 +1520,11 @@ impl SolveApp { } return; } + // Print Face (Ctrl+B) acts on the selected face at once: it has no dialog. + if id == "body.print_face" { + print_face_ui::toggle(self); + return; + } if id == "view.normal_to" { if self.run(id, json!({"camera": self.cam})).is_ok() { self.follow_view_request(true); @@ -1539,6 +1610,17 @@ impl SolveApp { let _ = self.run(id, json!({"mode":mode})); return; } + // A display choice opens its choices, as the navigation bar's menus do: run with no + // parameters it could only fail (#8868). + let choice = match id { + "view.visual_style" => Some(context_menu::Target::VisualStyle), + "view.tool" => Some(context_menu::Target::ViewTool), + _ => None, + }; + if let Some(target) = choice { + self.open_choice_menu(opener, target); + return; + } if id == "view.render_focus" { let _ = self.run(id, json!({"on": !self.shell.render_focus})); return; @@ -1653,7 +1735,7 @@ impl SolveApp { }); return; } - if !sketch_tools::start_hook(self, id) { + if !section_edit && !sketch_tools::start_hook(self, id) { return; } if remember_variant && solidcraft_engine::find_command(id).is_some_and(|c| c.info(&self.session).enabled) { @@ -1676,6 +1758,10 @@ impl SolveApp { design_properties::open(self); return; } + if id == "sketch.inspect" { + sketch_report::open(self); + return; + } if id == "assembly.bom" { bom_panel::open(self); return; @@ -1961,11 +2047,16 @@ impl SolveApp { let _ = self.run("component.activate", p); } - /// Turn the view to face the sketch just opened, and remember the view from `before` it and - /// the one facing it for when the sketch ends (#7038). - pub(crate) fn look_at_opened_sketch(&mut self, before: Camera) { - dialogs::look_at_sketch(self); + /// Turn the view to face the sketch just opened, unless the "Turn to face a sketch when it + /// opens" preference is off, and remember the view from `before` it and the one facing it for + /// when the sketch ends (#7038). Returns whether the view turned. + pub(crate) fn look_at_opened_sketch(&mut self, before: Camera) -> bool { + let turn = self.preferences.sketch_open_look_at; + if turn { + dialogs::look_at_sketch(self); + } self.pre_sketch_cam = self.session.active_sketch.map(|sketch| PreSketchView { sketch, before, facing: self.settled_cam() }); + turn } /// Where the camera is going: the end of the running camera animation, else where it is. @@ -2032,6 +2123,12 @@ impl SolveApp { let _ = self.run_heavy("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); } + /// Visibility in the editor's scene: hidden face-profile sources are temporarily shown + /// without changing the session's visibility or either history stack. + pub(crate) fn body_visible(&self, name: &str) -> bool { + self.session.body_visible(name) || self.dialog.as_ref().is_some_and(|d| d.edit_source_bodies.iter().any(|source| source == name)) + } + /// Everything shown as selected: the selection plus the open dialog's inputs. pub fn highlighted(&self) -> Vec { let mut v = self.session.selection.clone(); @@ -2112,6 +2209,9 @@ impl SolveApp { self.home.open = false; documents::prepare_open(self); if let Ok(r) = self.run("doc.open", json!({ "path": path })) { + // An opened design comes up at the home view, not at the angle the design before it + // was left at: `prepare_open` keeps the camera when it reuses a blank design. + self.cam.set_view(StandardView::Iso); self.fit_view(); stand_up::offer(self, path, &[], Some(&r)); } @@ -2162,6 +2262,10 @@ impl SolveApp { self.fit_view(); } + /// How many frames a fit waits for a viewport that can frame it before it gives up (about + /// two seconds): a window that never draws its viewport must not ask for frames forever. + const FIT_WAITS: u32 = 120; + pub fn fit_view(&mut self) { if render_workspace::result_active(self) { render_workspace::fit_result(self); @@ -2184,6 +2288,7 @@ impl SolveApp { self.viewport.orbit_free = None; let bounds = viewport::view_bounds(self); self.pending_fit = Some((self.cam, bounds, false)); + self.fit_waits = 0; self.cam = self.fitted(self.cam); } @@ -2192,6 +2297,7 @@ impl SolveApp { self.cancel_view_animation(); self.viewport.orbit_free = None; self.pending_fit = Some((self.cam, bounds, false)); + self.fit_waits = 0; self.cam = self.fitted_bounds(self.cam, &bounds); } @@ -2207,12 +2313,20 @@ impl SolveApp { cam } + /// `cam` framing `bounds` in the viewport as it is laid out now, or `None` when this layout + /// can't frame it: the viewport has not been drawn since the fit was asked for, so the + /// rectangle to frame into is the one from before the Home screen or a dialog covered it. + fn try_fitted_bounds(&self, cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Option { + let layout = self.viewport.layout.as_ref()?; + 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()) + } + fn fitted_bounds(&self, mut cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Camera { - if let Some(layout) = &self.viewport.layout { - if render_workspace::active(self) { - return viewport_layout::fit_product_bounds(cam, bounds, layout).unwrap_or(cam); - } - return viewport_layout::fit_bounds(cam, bounds, layout, self.view_fov()).unwrap_or(cam); + if self.viewport.layout.is_some() { + return self.try_fitted_bounds(cam, bounds).unwrap_or(cam); } // Framed at the angle the view draws with, keeping the camera's own. let previous = cam; @@ -2227,12 +2341,35 @@ impl SolveApp { if self.viewport.layout.is_some() && let Some((cam, bounds, animate)) = self.pending_fit.take() { - let to = if bounds.is_empty() { self.fitted(cam) } else { self.fitted_bounds(cam, &bounds) }; + // 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 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 + // frame it into the rectangle of whatever was there before and leave the design + // wherever the camera happened to be. Keep the request and frame it when the + // 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)); + } else { + self.fit_waits = 0; + self.cam = self.fitted_bounds(cam, &want); + } + ctx.request_repaint(); + return; + }; + self.fit_waits = 0; if animate { self.animate_to(to); } else { self.cam = to; self.cam_anim = None; + // An open's framing settles here, once the layout is known. An open still meshing + // takes this as the camera it framed from the outlines, so the framing it does + // when the last body is ready isn't mistaken for the user having moved the view. + open_meshing::note_framed(self); } ctx.request_repaint(); } @@ -2400,7 +2537,15 @@ impl SolveApp { } open_meshing::tick(self, ctx); open_meshing::refresh(self, ctx.input(|i| i.time)); - self.esc_handled = false; + // Routing survives a layout re-pass, but never the next input frame. The legacy + // policy retains its original per-pass cancellation flag behavior. + let first_pass = ctx.current_pass_index() == 0; + if first_pass { + self.escape_owner = None; + } + if first_pass || !crate::viewport_status::enabled(self) { + self.esc_handled = false; + } prefs::apply(self, ctx); // The view's own dark or light only changes tokens, not egui's visuals: no restyle. theme::set_viewport_dark(self.preferences.viewport_theme.dark()); @@ -2511,6 +2656,7 @@ impl SolveApp { /// Lay out the whole window. 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); 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. @@ -2602,6 +2748,7 @@ impl SolveApp { toolbar::toolbar(self, ui); } theme::TabAccent::publish(self, ui.ctx()); + browser::publish_row_prefs(self, ui.ctx()); if !floating { agent_chat::show(self, ui); } @@ -2700,6 +2847,7 @@ impl SolveApp { self.resolve_fit(ui.ctx()); params_dialog::show(self, ui.ctx()); design_properties::show(self, ui.ctx()); + sketch_report::show(self, ui.ctx()); bom_panel::show(self, ui.ctx()); material_card_review::show(self, ui.ctx()); recovery_ui::show(self, ui.ctx()); diff --git a/crates/ui-egui/src/look_at_tests.rs b/crates/ui-egui/src/look_at_tests.rs index 6908bfdb9f..afccfc48df 100644 --- a/crates/ui-egui/src/look_at_tests.rs +++ b/crates/ui-egui/src/look_at_tests.rs @@ -91,3 +91,43 @@ fn the_viewport_menu_looks_at_a_plane_under_the_cursor() { let normal = solidcraft_engine::geom::Plane::named("XZ").unwrap().normal(); assert!((h.app.cam.back().cross(normal)).len() < 1e-6, "facing the plane along its normal: {:?}", h.app.cam.back()); } + +/// #9029: Look At on a non-horizontal sketch squares its axes to the screen (not only faces it). +#[test] +fn look_at_squares_a_vertical_sketch_to_the_screen() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.look_at_anim = false; + h.app.run("sketch.create", json!({"plane": "XZ", "name": "Wall"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})).unwrap(); + crate::dialogs::look_at_sketch(&mut h.app); + let plane = h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().plane; + let (right, up, back) = h.app.cam.basis(); + assert!((back.cross(plane.normal())).len() < 1e-6, "facing the sketch: back={back:?} n={:?}", plane.normal()); + assert!(right.dot(plane.x).abs() > 0.999, "sketch x to the right: right={right:?} x={:?}", plane.x); + assert!(up.dot(plane.y) > 0.999, "sketch y upright: up={up:?} y={:?}", plane.y); +} + +/// #9029: editing a sketch after its component is turned faces the world plane and squares axes. +#[test] +fn editing_a_sketch_in_a_turned_component_faces_its_world_plane() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.look_at_anim = false; + h.app.run("component.create", json!({"name": "Part"})).unwrap(); + h.app.run("sketch.create", json!({"plane": "XY", "name": "Face"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let occ = h.app.session.doc.occurrences.iter().find(|o| h.app.session.doc.components.iter().any(|c| c.id == o.component && c.name == "Part")); + let Some(occ) = occ.cloned() else { panic!("Part occurrence") }; + h.app.run("occurrence.ground", json!({"occurrence": occ.id, "grounded": false})).unwrap(); + // Stand the part up: 90° about X so its XY faces world +Y. + h.app.run("occurrence.move", json!({"occurrence": occ.id, "axis": [1, 0, 0], "angle": "90 deg", "capture": true})).unwrap(); + let id = h.app.session.doc.find_feature("Face").map(|f| f.id).unwrap(); + h.app.edit_sketch(id); + let plane = h.app.session.world_state().sketch(id).unwrap().plane; + let (right, up, back) = h.app.cam.basis(); + assert!((back.cross(plane.normal())).len() < 1e-5, "facing world plane: back={back:?} n={:?}", plane.normal()); + assert!(right.dot(plane.x).abs() > 0.99, "x right: {right:?} vs {:?}", plane.x); + assert!(up.dot(plane.y) > 0.99, "y up: {up:?} vs {:?}", plane.y); +} diff --git a/crates/ui-egui/src/measurement_ui_tests.rs b/crates/ui-egui/src/measurement_ui_tests.rs index 8c50719219..d2a958c765 100644 --- a/crates/ui-egui/src/measurement_ui_tests.rs +++ b/crates/ui-egui/src/measurement_ui_tests.rs @@ -77,7 +77,7 @@ fn measurement_axis_distances_follow_the_upstream_centre_min_and_max_choices() { assert!(measured["distance_mm"].as_f64().unwrap() < 50. && measured["max"].as_f64().unwrap() > 50.); for (label, distance, from, to) in [ - ("Centre to centre", "center_distance", "center_from", "center_to"), + ("Center to center", "center_distance", "center_from", "center_to"), ("Min", "distance_mm", "from", "to"), ("Max", "max", "max_from", "max_to"), ] { diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index d8a63a2cc0..f52ff5e95a 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -587,9 +587,9 @@ fn folder_and_group_menus_make_rename_and_drop_groups() { act(&mut app, &t, "Show/Hide"); assert!(app.session.visibility.hidden_bodies.is_empty()); assert!(app.session.folder_hidden(&format!("c0/g{}", g.id))); - assert!(g.items.iter().all(|b| !app.session.body_visible(b))); + assert!(g.item_keys().all(|b| !app.session.body_visible(b))); act(&mut app, &t, "Show/Hide"); - assert!(g.items.iter().all(|b| app.session.body_visible(b))); + assert!(g.item_keys().all(|b| app.session.body_visible(b))); act(&mut app, &t, "Ungroup"); assert!(app.session.doc.browser_groups.is_empty()); act(&mut app, &folder, "New Group"); @@ -1466,6 +1466,8 @@ fn every_context_target_has_identified_icon_rows_including_submenus() { Target::Group { id: group_id, scope: "c0".into() }, Target::Origin, Target::Units, + Target::VisualStyle, + Target::ViewTool, Target::ToolbarCommand { id: "solid.extrude".into() }, Target::Toolbar, Target::HistoryControls, diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index a339e53047..7c9cf730e8 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -295,6 +295,39 @@ pub(crate) fn pinch_factor(input: &egui::InputState, options: egui::InputOptions factor } +/// The wheel and pinch of this frame for a widget that zooms and moves along one axis itself (a +/// range track), claimed so that nothing under it moves as well: the wheel's vertical points +/// (zoom), its sideways points (the content moves by them) and the pinch factor. Units and +/// modifier routing are egui's, as for the camera. +pub(crate) fn axis_wheel(ctx: &egui::Context, page_height: f32) -> (f32, f32, f32) { + let options = ctx.options(|o| o.input_options); + let (zoom, sideways, pinch) = ctx.input(|i| { + let (mut zoom, mut sideways) = (0.0, 0.0); + for event in &i.events { + let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue }; + if *phase != egui::TouchPhase::Move || modifiers.matches_any(options.zoom_modifier) { + continue; + } + let points = match unit { + egui::MouseWheelUnit::Point => *delta, + egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed, + egui::MouseWheelUnit::Page => *delta * page_height, + }; + let up = route_wheel_vertical(points, *modifiers, options); + zoom += up; + sideways += if up == 0.0 { points.x + points.y } else { points.x }; + } + // A zoom-modified wheel, skipped above, zooms the track once, as a pinch: no chosen + // trackpad modifier turns it into anything else here. + (zoom, sideways, pinch_factor(i, options, page_height, None)) + }); + let (zoom, sideways) = (if zoom.is_finite() { zoom } else { 0.0 }, if sideways.is_finite() { sideways } else { 0.0 }); + if zoom != 0.0 || sideways != 0.0 || pinch != 1.0 { + claim_wheel(ctx); + } + (zoom, sideways, pinch) +} + /// Give this frame's wheel to the widget under the pointer that uses it itself (a value field /// stepping its number, `value_step`): no scroll area or camera under it moves as well. pub(crate) fn claim_wheel(ctx: &egui::Context) { @@ -811,22 +844,26 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e /// The 3D mouse's motion this frame (#1336), on a page `rect` whose camera may do `caps`: the cap /// pans and zooms every page, and turns the view where the page may orbit. In a sketch, turning -/// stays off while the camera is in its initial sketch orientation. [`handle`] calls it for its page; the 3D -/// mouse only reads the device and queues the motion ([`crate::space_mouse::navigate_frame`]). +/// stays off while the camera is in its initial sketch orientation, unless the user lets the 3D +/// mouse turn sketches (`space_mouse_sketch_orbit`, #2227), as the mouse can. [`handle`] calls it +/// for its page; the 3D mouse only reads the device and queues the motion +/// ([`crate::space_mouse::navigate_frame`]). pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) { if app.viewport.device_motion.is_none() && app.viewport.bridge_motion.is_none() { return; } - let sketch_view_is_initial = solidcraft_engine::view::open_sketch_plane(&app.session).is_some_and(|plane| { - let normal = plane.normal(); - let mut initial = app.cam.looking_from(normal); - if normal.z.abs() > 0.999 { - initial.yaw = if normal.z > 0.0 { (-plane.x.y).atan2(plane.x.x) } else { plane.x.y.atan2(plane.x.x) }; - } - let (right, up, back) = app.cam.basis(); - let (initial_right, initial_up, initial_back) = initial.basis(); - right.dot(initial_right) > 0.999 && up.dot(initial_up) > 0.999 && back.dot(initial_back) > 0.999 - }); + let sketch_turns = app.preferences.space_mouse_sketch_orbit; + let sketch_view_is_initial = !sketch_turns + && solidcraft_engine::view::open_sketch_plane(&app.session).is_some_and(|plane| { + let normal = plane.normal(); + let mut initial = app.cam.looking_from(normal); + if normal.z.abs() > 0.999 { + initial.yaw = if normal.z > 0.0 { (-plane.x.y).atan2(plane.x.x) } else { plane.x.y.atan2(plane.x.x) }; + } + let (right, up, back) = app.cam.basis(); + let (initial_right, initial_up, initial_back) = initial.basis(); + right.dot(initial_right) > 0.999 && up.dot(initial_up) > 0.999 && back.dot(initial_back) > 0.999 + }); let turn = caps.orbit && !sketch_view_is_initial; let height = f64::from(rect.height().max(1.0)); // A device has no pointer, so opt-in zoom holds the model at the middle of the view (#7435). @@ -851,17 +888,7 @@ pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) { } }); if let Some(queued) = app.viewport.device_motion.take() { - device_camera( - &mut app.cam, - queued.motion, - queued.seconds, - height, - turn, - app.preferences.zoom_reverse, - app.preferences.free_orbit, - anchor, - pivot, - ); + device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.free_orbit, anchor, pivot); } // A device app's motion (the navigation bridge), on the same terms. if let Some(queued) = app.viewport.bridge_motion.take() { @@ -920,13 +947,14 @@ pub(crate) fn bridge_camera( /// the frame rate. With `turn` off the view keeps its orientation. `anchor` is the model point the /// view holds while it zooms, as the wheel's cursor point (none zooms through the model, #7435). /// `pivot` is the point the view turns around and keeps still on screen (none: the view's centre). +/// Reverse zoom direction is a wheel setting and leaves the cap alone: the cap's own direction is +/// its per-axis invert, already in `motion` (#8993). pub(crate) fn device_camera( cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, - reverse_zoom: bool, free_orbit: bool, anchor: Option, pivot: Option, @@ -939,8 +967,7 @@ pub(crate) fn device_camera( // Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's // profile can swap the physical forward/back and up/down axes before they reach here. cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height); - let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 }; - cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), anchor); + cam.zoom_at((-y * 1.5 * seconds).exp(), anchor); if turn { // These are logical 3DxWare coordinates after the profile mapping: Rx is Tilt, Ry is // Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected navigation @@ -1710,7 +1737,7 @@ mod tests { } // Other presets keep the existing upright camera behavior. let mut cam = Camera { yaw: 0.0, pitch: 1.5, ..Default::default() }; - device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, false, None, None); + device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, None, None); assert!(cam.pitch < std::f64::consts::FRAC_PI_2 && cam.pitch > 1.5); } @@ -2294,6 +2321,45 @@ mod tests { super::device(&mut app, page, super::NavCaps::MODEL); assert_eq!(app.cam, still); } + + /// With "3D mouse turns sketches" on (#2227), the cap and a device app turn a sketch seen square + /// on, as the mouse does; pages that may not orbit still only pan. + #[test] + fn the_3d_mouse_turns_a_square_on_sketch_when_the_preference_allows_it() { + use crate::space_mouse::{BridgeMotion, DeviceMotion, Motion}; + use solidcraft_engine::render::Camera; + let mut s = solidcraft_engine::Session::default(); + s.execute("sketch.create", &serde_json::json!({"plane": "XY"})).unwrap(); + let mut app = crate::SolveApp::new(s, crate::Services::default()); + assert!(!app.preferences.space_mouse_sketch_orbit, "off by default"); + app.preferences.space_mouse_sketch_orbit = true; + let square_on = Camera { yaw: 0.0, pitch: std::f64::consts::FRAC_PI_2, ..Camera::default() }; + let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let turn = |c: &Camera| (c.yaw, c.pitch, c.roll); + + app.cam = square_on; + app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [0.3, 0.3, 0.0] }, seconds: 0.05 }); + super::device(&mut app, page, super::NavCaps::MODEL); + assert_ne!(turn(&app.cam), turn(&square_on), "the cap turns the square-on sketch"); + + app.cam = square_on; + app.viewport.bridge_motion = Some(BridgeMotion { orbit: [6.0, 6.0], ..Default::default() }); + super::device(&mut app, page, super::NavCaps::MODEL); + assert_ne!(turn(&app.cam), turn(&square_on), "a device app turns it too"); + + // A page that may not orbit keeps its orientation whatever the preference. + app.cam = square_on; + app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [0.3, 0.3, 0.3] }, seconds: 0.05 }); + super::device(&mut app, page, super::NavCaps { orbit: false, ..super::NavCaps::MODEL }); + assert_eq!(turn(&app.cam), turn(&square_on)); + + // Off again: the square-on sketch keeps its orientation, as before. + app.preferences.space_mouse_sketch_orbit = false; + app.cam = square_on; + app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [0.3, 0.3, 0.3] }, seconds: 0.05 }); + super::device(&mut app, page, super::NavCaps::MODEL); + assert_eq!(turn(&app.cam), turn(&square_on)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/navigation_bar.rs b/crates/ui-egui/src/navigation_bar.rs index 9bcba8bc91..d833582038 100644 --- a/crates/ui-egui/src/navigation_bar.rs +++ b/crates/ui-egui/src/navigation_bar.rs @@ -17,6 +17,14 @@ pub(crate) const SELECTION_MODE_ICONS: &[(&str, &str)] = &[ ("sketch", "select_sketch"), ]; +/// The engine's style ids in index order (`ui.visual_style` 0..3), with the English menu label. +pub(crate) const VISUAL_STYLES: [(&str, &str); 4] = [ + ("shaded_with_edges", "Shaded with edges"), + ("shaded", "Shaded"), + ("wireframe", "Wireframe"), + ("shaded_with_hidden_edges", "Shaded with hidden edges"), +]; + pub(crate) fn visual_style_icon(style: u8) -> &'static str { match style { 1 => "style_shaded", diff --git a/crates/ui-egui/src/navigation_bar_view.rs b/crates/ui-egui/src/navigation_bar_view.rs index 3668ec8a13..99d8f050dd 100644 --- a/crates/ui-egui/src/navigation_bar_view.rs +++ b/crates/ui-egui/src/navigation_bar_view.rs @@ -433,7 +433,7 @@ pub(crate) fn context_menu(app: &mut SolveApp, response: &egui::Response, index: fn style_menu(app: &mut SolveApp, ui: &mut egui::Ui) -> bool { let mut chosen = false; - for (index, style) in ["shaded_with_edges", "shaded", "wireframe", "shaded_with_hidden_edges"].into_iter().enumerate() { + for (index, style) in navigation_bar::VISUAL_STYLES.iter().map(|(style, _)| *style).enumerate() { let label = format!("navbar.style.{style}"); let mut item = ui_kit::NavigationMenuRow::new(style, app.tr(&label), navigation_bar::visual_style_icon(index as u8)); item.selected = app.ui.visual_style == index as u8; diff --git a/crates/ui-egui/src/occurrence_ui_tests.rs b/crates/ui-egui/src/occurrence_ui_tests.rs index d4bda9b9f7..03abe02dd0 100644 --- a/crates/ui-egui/src/occurrence_ui_tests.rs +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -432,11 +432,10 @@ fn a_folded_body_sits_one_level_under_its_nested_placement() { h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Pin"})).unwrap(); h.app.run("component.activate", json!({"component":0})).unwrap(); h.app.tree.collapsed.insert(format!("{}/bodies", super::occurrence_key(&[parent, child]))); - // How many levels the body's row sits under its placement's (each eye sits at the same offset - // in its row). + // How many levels the body's row sits under its placement's. let levels = || -> Option { - let placement = crate::scenario::handle_at(&format!("eye:occurrence:{parent}/{child}"))?; - let body = crate::scenario::handle_at("eye:Pin")?; + let placement = crate::scenario::handle_at("indent:Child:1")?; + let body = crate::scenario::handle_at("indent:Pin")?; Some(((body.x - placement.x) / super::INDENT).round() as i32) }; for (mode, want) in [(BodiesFolders::Several, Some(1)), (BodiesFolders::Never, Some(1)), (BodiesFolders::Always, None)] { @@ -494,7 +493,7 @@ fn a_folded_folders_menu_items_move_to_the_placements_menu() { let group = context_menu::items(&h.app, &target).into_iter().find(|i| i.id == "ui.groupSelected").unwrap(); context_menu::run_item(&mut h.app, &group, egui::Pos2::ZERO); let made = h.app.session.doc.browser_groups.iter().find(|g| g.component == component).unwrap(); - assert_eq!(made.items, ["Body1", "Second"]); + assert_eq!(made.item_keys().collect::>(), ["Body1", "Second"]); h.app.preferences.browser_bodies_folders = BodiesFolders::Always; assert!(!has(&h, "component.from_bodies"), "Always: on the folder's own menu"); } @@ -539,3 +538,26 @@ fn dragging_an_occurrence_row_onto_another_moves_it_into_that_component() { let o = h.app.session.doc.occurrences.iter().find(|o| o.id == a_occ).unwrap(); assert_eq!(o.parent, 0, "A's occurrence moved back out to the design"); } + +/// Dropping a placement row on the top of a sibling's row puts it before that sibling, in the +/// order saved with the design; the rows follow. (The middle of a row moves it into that +/// component, as above.) +#[test] +fn dragging_an_occurrence_row_onto_the_top_of_a_sibling_reorders_them() { + let mut h = Harness::new(); + let mut placed = Vec::new(); + for name in ["A", "B", "C"] { + placed.push(h.app.run("component.create", json!({"name":name})).unwrap()["occurrence"].as_u64().unwrap()); + h.app.run("component.activate", json!({"component":0})).unwrap(); + } + let [a, b, c] = [placed[0], placed[1], placed[2]]; + h.frames(3); + let row = |id: u64| crate::scenario::handle_at(&format!("row:occurrence:{id}")).unwrap(); + let (from, to) = (row(c), row(a)); + assert_eq!(h.call("ui.drag", json!({"x0":from.x,"y0":from.y,"x1":to.x,"y1":to.y - 10.0,"steps":12}))["ok"], true); + h.frames(3); + let saved = &h.app.session.doc.browser_order["0/components"]; + assert_eq!(*saved, [c, a, b].map(|id| solidcraft_engine::doc::BrowserEntry::item(&id.to_string()))); + assert!(row(c).y < row(a).y && row(a).y < row(b).y, "the rows follow the saved order"); + assert!(h.app.session.doc.occurrences.iter().all(|o| o.parent == 0), "none moved into another"); +} diff --git a/crates/ui-egui/src/open_meshing.rs b/crates/ui-egui/src/open_meshing.rs index 420fae03a3..9f96b91923 100644 --- a/crates/ui-egui/src/open_meshing.rs +++ b/crates/ui-egui/src/open_meshing.rs @@ -135,6 +135,16 @@ pub(crate) fn start(app: &mut SolveApp, remember: Option) { } } +/// Take the camera as the one the open framed, after a fit the app itself resolved (the view is +/// framed again as the design goes in, once the layout is known): `tick` frames the meshes only +/// while the camera is still the one it framed, which is how the user taking the view over keeps +/// their own framing. +pub(crate) fn note_framed(app: &mut SolveApp) { + if let Some(m) = app.open_meshing.as_mut().filter(|m| !m.complete && m.framed.is_some()) { + m.framed = Some(app.cam); + } +} + /// How far the meshing is: bodies done, and of how many. pub(crate) fn progress(app: &SolveApp) -> Option<(usize, usize)> { app.open_meshing.as_ref().filter(|m| !m.complete).map(|m| (m.done.load(Ordering::Acquire).min(m.world.bodies.len()), m.world.bodies.len())) diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 9f90b1d4c2..2b652ebed4 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -2,8 +2,11 @@ //! //! A window over the engine's `parameters.*` commands: favourites first, then user parameters //! (name, unit, expression, value, comment; add, rename, delete) and model parameters grouped by -//! the sketch or feature they drive (editable). Expressions validate as you type +//! the sketch or feature they drive (editable). The Name heading sorts each section, and each +//! model group, by name in natural order (#8563). Expressions validate as you type //! (`expr.evaluate`) with a live model preview; Enter or leaving the field applies them. +//! With "Apply each change as you leave its field" off (a remembered preference, #9279), they +//! wait instead, without a preview, until Apply or OK sets them in one `parameters.change`. //! Escape closes the window and discards pending edits and their preview. //! Import and export go through `parameters.import` / `parameters.export`. //! @@ -27,6 +30,9 @@ const UNITS: [&str; 10] = ["mm", "cm", "m", "in", "ft", "deg", "rad", "", "um", #[derive(Default)] pub struct ParamsDialog { pub filter: String, + /// The order by name inside each section (#8563); remembered across openings like the + /// sections' open state. + pub(crate) sort: ui_kit::Sort, reset_scroll: bool, /// Expressions being edited, by parameter name. pub editing: std::collections::BTreeMap, @@ -185,6 +191,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { publish(app, ctx); } let Some(mut d) = app.params.take() else { return }; + d.sort = ctx.data_mut(|m| m.get_persisted(sort_id())).unwrap_or_default(); + let sort = d.sort; ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); let viewport = crate::agent_chat::tool_space(app, crate::shell::content_rect(app, app.viewport.rect.unwrap_or_else(|| ctx.content_rect()))); if !viewport.is_finite() || !viewport.is_positive() { @@ -225,9 +233,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { d.lists += 1; rows }; - let mut close = false; + let mut outcome = (false, ui_kit::Action::None); + let live = app.preferences.parameters_apply_live; + let held_status = if live { None } else { held_status(held(&d, &all).len()) }; panel.window(ctx).show(ctx, |ui| { - let (closed, action) = panel.feature( + outcome = panel.feature( ui, ui_kit::FeatureOptions { icon: solidcraft_engine::find_command("parameters.change").map_or("", |c| c.icon), @@ -235,12 +245,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { drag_key: "PARAMETERS", moved, docked, - apply_label: None, + apply_label: (!live).then_some("Apply"), }, false, true, |ui| { - ui_kit::text(ui, crate::i18n::tr("manage.parameters_hint"), ui_kit::Text::Hint("manage")); + ui_kit::text(ui, hint(live), ui_kit::Text::Hint("manage")); + apply_live_checkbox(app, ui, &mut d, &all); ui_kit::row(ui, "Filter", |ui| { let id = ui.make_persistent_id("parameter_filter"); ui_kit::search_field(ui, id, &mut d.filter, "Filter"); @@ -278,30 +289,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .map(|r| ui_kit::ContentWidth::name(ui, &r.name)) .fold(crate::theme::Tokens::get().form_label_width, f32::max) .min(crate::theme::Tokens::get().form_card_width); - let favorites: Vec<_> = visible.iter().copied().filter(|r| r.favorite).collect(); + let Sections { favorites, users, groups } = sections(&visible, d.sort); if !favorites.is_empty() { ui_kit::section_count(ui, "favorites", "Favorites", true, Some(favorites.len()), |ui| { table(app, ui, &mut d, &favorites, "fav", name_floor) }); } - let users: Vec<_> = visible.iter().copied().filter(|r| r.source == "user").collect(); ui_kit::section_count(ui, "users", "User Parameters", true, Some(users.len()), |ui| { table(app, ui, &mut d, &users, "user", name_floor); }); ui_kit::section(ui, "models", "Model Parameters", true, |ui| { - // Group once, in listing order, rather than scanning every parameter for - // each feature on every frame (#2733). - let mut groups: Vec<(&str, Vec<&Row>)> = Vec::new(); - let mut indices = std::collections::HashMap::new(); - for r in visible.iter().filter(|r| r.source != "user") { - let index = *indices.entry(r.group.as_str()).or_insert_with(|| { - groups.push((&r.group, Vec::new())); - groups.len() - 1 - }); - if let Some((_, members)) = groups.get_mut(index) { - members.push(r); - } - } ui.scope(|ui| { ui.spacing_mut().indent = crate::theme::Tokens::get().space_14; ui.indent("model_groups", |ui| { @@ -329,12 +326,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }); }); }); - ui_kit::ComputeStatus { text: None, busy: false, applying: false } + ui_kit::ComputeStatus { text: held_status, busy: false, applying: false } }, ); d.reset_scroll = false; - close = closed || matches!(action, ui_kit::Action::Ok | ui_kit::Action::Cancel); }); + // Held edits (#9279) are set by Apply and OK; Cancel, the close button and Escape drop them. + let (closed, action) = outcome; + let applied = live || !matches!(action, ui_kit::Action::Ok | ui_kit::Action::Apply) || apply_held(app, &mut d, &all); + let close = closed || action == ui_kit::Action::Cancel || (action == ui_kit::Action::Ok && applied); // Each cell owns fresh Enter from raw events. The panel itself must not submit on // Enter in the filter or with unrelated focus (the legacy editor left those alone), but // the filter, rename and comment fields still see Enter first to give up focus. @@ -344,11 +344,58 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if let Some(rect) = crate::scenario::rect_of("dialog:header") { crate::scenario::publish_handle("params:title", rect.center()); } + if d.sort != sort { + let chosen = d.sort; + ctx.data_mut(|m| m.insert_persisted(sort_id(), chosen)); + } if !close { app.params = Some(d); } } +/// Where the chosen order is kept: egui's persisted memory, like a section's open state. +fn sort_id() -> egui::Id { + egui::Id::new("sc_params_sort") +} + +/// The dialog's sections, each in the chosen order. +struct Sections<'a> { + favorites: Vec<&'a Row>, + users: Vec<&'a Row>, + /// Model parameters by the sketch or feature that drives them, groups in listing order. + groups: Vec<(&'a str, Vec<&'a Row>)>, +} + +/// Split the listed rows into sections and sort each one by name on its own (#8563): a sort +/// orders the rows inside Favorites, User Parameters and each model group, never moves a row +/// to another section, and keeps the groups themselves in the design's order. +fn sections<'a>(visible: &[&'a Row], sort: ui_kit::Sort) -> Sections<'a> { + fn by_name<'o>(r: &'o &Row) -> &'o str { + &r.name + } + let mut favorites: Vec<_> = visible.iter().copied().filter(|r| r.favorite).collect(); + sort.apply(&mut favorites, by_name); + let mut users: Vec<_> = visible.iter().copied().filter(|r| r.source == "user").collect(); + sort.apply(&mut users, by_name); + // Group once, in listing order, rather than scanning every parameter for each feature on + // every frame (#2733). + let mut groups: Vec<(&str, Vec<&Row>)> = Vec::new(); + let mut indices = std::collections::HashMap::new(); + for r in visible.iter().copied().filter(|r| r.source != "user") { + let index = *indices.entry(r.group.as_str()).or_insert_with(|| { + groups.push((&r.group, Vec::new())); + groups.len() - 1 + }); + if let Some((_, members)) = groups.get_mut(index) { + members.push(r); + } + } + for (_, members) in &mut groups { + sort.apply(members, by_name); + } + Sections { favorites, users, groups } +} + fn model_group(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, group: &str, rows: &[&Row], name_floor: f32) { // Focused editors and pending drafts must still receive focus-loss/commit events after // scrolling away. The measured size is used only while the group is idle. @@ -417,7 +464,18 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R Column::new("Comment", 1.0).flex(), Column::new("Actions", 0.0).icon_only().min_content_width(ui_kit::table_row_height()), ]; - ui_kit::table(ui, salt, &columns, crate::i18n::tr("manage.empty"), |ui, table| { + // An expression being edited may open over the cells to its right, up to the table's edge. + let right = ui.max_rect().right().min(ui.clip_rect().right()); + ui_kit::table_custom(ui, salt, &columns, crate::i18n::tr("manage.empty"), |ui, table| { + // The Name heading sorts every section at once, each inside itself (#8563). + table.headers(ui, |ui, index| { + if index != 1 { + return false; + } + let response = ui_kit::sort_header(ui, "Name", &mut d.sort); + crate::scenario::publish_handle(&format!("params:sort:{salt}"), response.rect.center()); + true + }); for (index, r) in rows.iter().enumerate() { // Skip cells outside the clip while preserving the table's row geometry (#2723). // Drafts remain live until their edits have been committed. @@ -433,11 +491,14 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R d.error = app.run("parameters.favorite", json!({"name": r.name, "favorite": !r.favorite})).err(); } if salt == "user" { + // A sorted list doesn't show the stored order, so moving a row in it + // would jump to a place nobody sees; moving waits for the listed order. + let listed = d.sort == ui_kit::Sort::Listed; for (glyph, tip, neighbour, side) in [ (ui_kit::MOVE_UP, "Move up", index.checked_sub(1).and_then(|i| rows.get(i)), "before"), (ui_kit::MOVE_DOWN, "Move down", rows.get(index + 1), "after"), ] { - let response = ui_kit::compact_button(ui, glyph, tip, neighbour.is_some(), false); + let response = ui_kit::compact_button(ui, glyph, tip, listed && neighbour.is_some(), false); crate::scenario::publish_handle(&format!("param:{side}:{}", r.name), response.rect.center()); if response.clicked() && let Some(neighbour) = neighbour @@ -492,11 +553,16 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R let mut expression = d.editing.get(&r.name).cloned().unwrap_or_else(|| r.expression.clone()); let live = (expression != r.expression).then(|| check(app, &expression, &r.unit)); let invalid = matches!(live, Some(Err(_))) || (live.is_none() && r.error.is_some()); + let (shown, error) = match (&live, &r.error) { + (Some(Err(message)), _) | (None, Some(message)) => (message.clone(), true), + (Some(Ok(value)), _) => (value.clone(), false), + (None, None) => (show_value(r), false), + }; row.cell(|ui| { let id = ui.make_persistent_id(("pexpr", salt, &r.name)); let focused = ui.memory(|m| m.has_focus(id) || m.had_focus_last_frame(id)); - let response = ui_kit::cell_editor(ui, id, &mut expression, "Expression", invalid); - crate::scenario::publish_handle(&format!("param:{}", r.name), response.rect.center()); + let response = ui_kit::wide_cell_editor(ui, id, &mut expression, "Expression", invalid, right, (&shown, error)); + crate::scenario::publish_target_as(&format!("param:{}", r.name), &format!("param:{}", r.name), response.rect); if salt == "fav" { crate::scenario::publish_handle(&format!("param:favorite:{}", r.name), response.rect.center()); } @@ -504,13 +570,18 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if response.changed() || completed { d.editing.insert(r.name.clone(), expression.clone()); } - if response.has_focus() && matches!(live, Some(Ok(_))) { + // Held edits (#9279) preview nothing: the preview is a rebuild too. + let apply_live = app.preferences.parameters_apply_live; + if apply_live && response.has_focus() && matches!(live, Some(Ok(_))) { d.preview = Some((r.name.clone(), expression.clone())); } let entered = !completed && enter_for(ui, focused); if !completed && (response.lost_focus() || entered) { d.preview = None; - if expression != r.expression { + if expression != r.expression && !apply_live { + // Kept in `editing` for Apply or OK, checked now. + d.error = check(app, &expression, &r.unit).err(); + } else if expression != r.expression { d.error = check(app, &expression, &r.unit) .and_then(|_| app.run("parameters.change", json!({"name":r.name,"expression":expression}))) .err(); @@ -523,12 +594,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R } }); row.cell(|ui| { - let (text, error) = match (&live, &r.error) { - (Some(Err(message)), _) | (None, Some(message)) => (message.clone(), true), - (Some(Ok(value)), _) => (value.clone(), false), - (None, None) => (show_value(r), false), - }; - ui_kit::text(ui, &text, ui_kit::Text::Value(&format!("value:{}", r.name), error)).on_hover_text(&text); + ui_kit::text(ui, &shown, ui_kit::Text::Value(&format!("value:{}", r.name), error)).on_hover_text(&shown); }); row.cell(|ui| { let mut comment = d.comments.get(&r.name).cloned().unwrap_or_else(|| r.comment.clone()); @@ -555,7 +621,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R } if salt == "user" { if d.adding { - ui.push_id("parameter_draft", |ui| add_row(app, ui, table, d)); + ui.push_id("parameter_draft", |ui| add_row(app, ui, table, d, right)); } if table.add_prompt(ui, "Add parameter", "Add parameter").clicked() { d.adding = true; @@ -584,8 +650,58 @@ fn enter_for(ui: &mut egui::Ui, focused: bool) -> bool { }) } +/// The expression edits waiting for Apply (#9279): each draft that differs from the design. +fn held(d: &ParamsDialog, rows: &[Row]) -> serde_json::Map { + rows.iter().filter_map(|r| d.editing.get(&r.name).filter(|e| **e != r.expression).map(|e| (r.name.clone(), json!(e)))).collect() +} + +/// The hint at the top of the dialog: when edits apply, as the check box under it says. +fn hint(live: bool) -> &'static str { + crate::i18n::tr(if live { "manage.parameters_hint" } else { "parameters.held_hint" }) +} + +/// The footer's note of the edits waiting for Apply, if any. +fn held_status(count: usize) -> Option { + match count { + 0 => None, + 1 => Some(crate::i18n::tr("parameters.held_one").to_string()), + n => Some(crate::i18n::format("parameters.held", &[("count", &n.to_string())])), + } +} + +/// Set the edits waiting for Apply together: one `parameters.change`, so the design rebuilds once +/// and one undo takes them all back. A single edit goes as `name, expression`, which can also +/// rename (`new = …`). False, with the error shown and the edits kept, when it is refused. +fn apply_held(app: &mut SolveApp, d: &mut ParamsDialog, rows: &[Row]) -> bool { + let changes = held(d, rows); + let params = match changes.iter().next() { + None => return true, + Some((name, expression)) if changes.len() == 1 => json!({"name": name, "expression": expression}), + Some(_) => json!({"changes": changes}), + }; + d.error = app.run("parameters.change", params).err(); + if d.error.is_some() { + return false; + } + for name in changes.keys() { + d.editing.remove(name); + } + true +} + +/// "Apply each change as you leave its field", remembered across sessions (#9279). Turning it +/// back on applies what was waiting. +fn apply_live_checkbox(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[Row]) { + let response = ui_kit::checkbox(ui, crate::i18n::tr("parameters.apply_live"), &mut app.preferences.parameters_apply_live) + .on_hover_text(crate::i18n::tr("parameters.apply_live_tip")); + crate::scenario::publish_handle("params:apply_live", response.rect.center()); + if response.changed() && app.preferences.parameters_apply_live { + apply_held(app, d, rows); + } +} + /// The add row, one cell per column of the user table it ends. -fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, table: &mut ui_kit::Table<'_>, d: &mut ParamsDialog) { +fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, table: &mut ui_kit::Table<'_>, d: &mut ParamsDialog, right: f32) { let name_id = ui.make_persistent_id("pnew_name"); let expr_id = ui.make_persistent_id("pnew_expr"); let comment_id = ui.make_persistent_id("pnew_comment"); @@ -616,10 +732,14 @@ fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, table: &mut ui_kit::Table<'_>, crate::scenario::publish_handle("param:new:unit", response.rect.center()); }); let unit = UNITS.get(d.new_unit).copied().unwrap_or("mm"); + // A blank expression is not yet an error: nothing to show, and nothing to add. + let live = if d.new_expr.trim().is_empty() { Ok(String::new()) } else { new_check(app, d, unit) }; + let (value, invalid) = match live { + Ok(v) => (v, false), + Err(e) => (e, true), + }; row.cell(|ui| { - // A blank expression is not yet an error: nothing to show, and nothing to add. - let invalid = !d.new_expr.trim().is_empty() && new_check(app, d, unit).is_err(); - let response = ui_kit::cell_editor(ui, expr_id, &mut d.new_expr, "Expression", invalid); + let response = ui_kit::wide_cell_editor(ui, expr_id, &mut d.new_expr, "Expression", invalid, right, (&value, invalid)); select_all_on_tab(ui, &response, &d.new_expr); crate::scenario::publish_handle("param:new:expression", response.rect.center()); let completed = complete(ui, &response, &mut d.new_expr); @@ -627,11 +747,6 @@ fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, table: &mut ui_kit::Table<'_>, left |= !completed && response.lost_focus(); }); row.cell(|ui| { - let live = if d.new_expr.trim().is_empty() { Ok(String::new()) } else { new_check(app, d, unit) }; - let (value, invalid) = match live { - Ok(v) => (v, false), - Err(e) => (e, true), - }; ui_kit::text(ui, &value, ui_kit::Text::Value("new_value", invalid)).on_hover_text(&value); }); row.cell(|ui| { @@ -734,6 +849,8 @@ pub struct Suggestion { struct Cache { revision: u64, all: Arc>, + /// How many parameters Down arrow lists (`Prefs::parameter_list_rows`). + every: usize, } fn cache_id() -> egui::Id { @@ -744,7 +861,9 @@ fn cache_id() -> egui::Id { /// design where expression boxes can reach it. Called once per frame. pub fn publish(app: &mut SolveApp, ctx: &egui::Context) { let rev = app.session.revision; - if ctx.data(|d| d.get_temp::(cache_id())).is_some_and(|c| c.revision == rev) { + let every = + usize::from(app.preferences.parameter_list_rows.clamp(*crate::prefs::PARAMETER_LIST_ROWS.start(), *crate::prefs::PARAMETER_LIST_ROWS.end())); + if ctx.data(|d| d.get_temp::(cache_id())).is_some_and(|c| c.revision == rev && c.every == every) { return; } let mut all: Vec = Vec::new(); @@ -763,7 +882,7 @@ pub fn publish(app: &mut SolveApp, ctx: &egui::Context) { } } } - ctx.data_mut(|d| d.insert_temp(cache_id(), Cache { revision: rev, all: Arc::new(all) })); + ctx.data_mut(|d| d.insert_temp(cache_id(), Cache { revision: rev, all: Arc::new(all), every })); } /// The identifier being typed before `cursor` (chars) and where it starts. @@ -802,6 +921,14 @@ pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) - all.iter().filter(|s| starts(s) && offered(s)).chain(all.iter().filter(|s| within(s) && offered(s))).take(8).cloned().collect() } +/// The named parameters, in the order the list offers them (favourites and the design's own +/// first), up to the Preferences' cap rather than a typed word's eight: what Down arrow in an +/// empty expression box shows (#9246). +pub(crate) fn every_parameter(ctx: &egui::Context) -> Vec { + let Some(cache) = ctx.data(|d| d.get_temp::(cache_id())) else { return Vec::new() }; + cache.all.iter().filter(|s| s.kind == "parameter").take(cache.every).cloned().collect() +} + /// Is `word` itself one of the known tokens (a parameter or dimension name, a constant or a /// unit)? Such a word is ready to submit as typed; the popup then only offers longer names. pub(crate) fn exact_token(ctx: &egui::Context, word: &str) -> bool { @@ -1019,6 +1146,106 @@ mod tests { assert_eq!(escape_state(&h), before); } + /// #9279: a box driven by `w` and `h`, the Parameters dialog open with live updates off. + fn held_dialog() -> crate::scenario::Harness { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.parameters_apply_live = false; + h.app.run("parameters.add", json!({"name":"w","expression":"10 mm"})).unwrap(); + h.app.run("parameters.add", json!({"name":"h","expression":"20 mm"})).unwrap(); + h.app.run("solid.box", json!({"length":"w","width":"w","height":"h"})).unwrap(); + h.step(&json!({"start":"parameters.change"})).unwrap(); + h.frames(3); + h + } + + fn expression(h: &crate::scenario::Harness, name: &str) -> String { + h.app.session.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() + } + + /// Type into a parameter's expression and leave the field, as the user moves on. + fn hold(h: &mut crate::scenario::Harness, name: &str, text: &str) { + escape_type(h, &format!("param:{name}"), text); + h.step(&json!({"click":{"handle":"dialog:Filter"}})).unwrap(); + } + + #[test] + fn held_edits_wait_for_apply_and_go_in_together_as_one_undo_step() { + let mut h = held_dialog(); + let (revision, undo) = (h.app.session.revision, h.app.session.undo.len()); + hold(&mut h, "w", "30 mm"); + hold(&mut h, "h", "w * 2"); + assert_eq!((expression(&h, "w").as_str(), expression(&h, "h").as_str()), ("10 mm", "20 mm"), "leaving a field changes nothing yet"); + assert_eq!((h.app.session.revision, h.app.session.undo.len()), (revision, undo), "no rebuild and no undo step while held"); + let d = h.app.params.as_ref().unwrap(); + assert!(d.preview.is_none() && d.error.is_none()); + assert_eq!(d.editing.len(), 2); + h.step(&json!({"click":{"handle":"dialog:Apply"}})).unwrap(); + assert_eq!((expression(&h, "w").as_str(), expression(&h, "h").as_str()), ("30 mm", "w * 2")); + assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step for both"); + let d = h.app.params.as_ref().expect("Apply keeps the dialog open"); + assert!(d.editing.is_empty() && d.error.is_none()); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!((expression(&h, "w").as_str(), expression(&h, "h").as_str()), ("10 mm", "20 mm"), "one undo takes both back"); + } + + #[test] + fn held_edits_go_in_with_ok_and_are_dropped_by_cancel_or_escape() { + let mut h = held_dialog(); + let before = escape_state(&h); + hold(&mut h, "w", "30 mm"); + h.step(&json!({"click":{"handle":"dialog:Cancel"}})).unwrap(); + assert!(h.app.params.is_none()); + assert_eq!(escape_state(&h), before, "Cancel drops held edits"); + h.step(&json!({"start":"parameters.change"})).unwrap(); + h.frames(2); + hold(&mut h, "w", "30 mm"); + escape_key(&mut h, true); + assert!(h.app.params.is_none()); + assert_eq!(escape_state(&h), before, "Escape drops held edits"); + escape_key(&mut h, false); + h.step(&json!({"start":"parameters.change"})).unwrap(); + h.frames(2); + hold(&mut h, "w", "30 mm"); + h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap(); + assert!(h.app.params.is_none()); + assert_eq!(expression(&h, "w"), "30 mm", "OK applies held edits"); + } + + #[test] + fn a_refused_held_edit_keeps_the_dialog_open_with_its_error() { + let mut h = held_dialog(); + hold(&mut h, "w", "30 mm"); + hold(&mut h, "h", "nonexistent * 2"); + assert!(h.app.params.as_ref().unwrap().error.is_some(), "a held edit is checked when its field is left"); + h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap(); + let d = h.app.params.as_ref().expect("OK keeps the dialog open when the edits are refused"); + assert!(d.error.is_some()); + assert_eq!(d.editing.len(), 2, "the held edits are kept to correct"); + assert_eq!(expression(&h, "w"), "10 mm", "all or nothing"); + } + + #[test] + fn the_apply_live_choice_is_remembered_and_turning_it_on_applies_what_waits() { + assert!(crate::prefs::Prefs::default().parameters_apply_live, "live updates stay the default"); + // The hint above the check box says when edits apply, whichever way it is set. + assert_eq!(hint(true), crate::i18n::tr("manage.parameters_hint")); + assert_ne!(hint(false), hint(true)); + assert!(hint(false).contains("Apply") && !hint(false).contains("leaving"), "{}", hint(false)); + let mut h = held_dialog(); + hold(&mut h, "w", "30 mm"); + h.step(&json!({"click":{"handle":"params:apply_live"}})).unwrap(); + assert!(h.app.preferences.parameters_apply_live); + assert_eq!(expression(&h, "w"), "30 mm"); + assert!(h.app.params.as_ref().unwrap().editing.is_empty()); + h.step(&json!({"click":{"handle":"params:apply_live"}})).unwrap(); + assert!(!h.app.preferences.parameters_apply_live); + let saved = h.app.prefs(); + let mut other = crate::scenario::Harness::new(); + other.app.load_prefs(&saved); + assert!(!other.app.preferences.parameters_apply_live, "the choice persists with the preferences"); + } + #[test] fn words_and_suggestions() { assert_eq!(word_before("2 * wid", 7), (4, "wid".to_string())); @@ -1150,6 +1377,110 @@ mod tests { assert!(h.app.params.as_ref().unwrap().error.is_none()); } + /// #8563: sorting orders each section, and each model group, by itself. + #[test] + fn sorting_orders_each_section_and_model_group_on_its_own() { + let row = |name: &str, source: &str, group: &str, favorite: bool| Row { + name: name.into(), + source: source.into(), + group: group.into(), + favorite, + ..Default::default() + }; + let listed = [ + row("d10", "user", "", true), + row("width", "user", "", false), + row("d2", "user", "", false), + row("Alpha", "user", "", true), + row("d1", "user", "", false), + row("s_d3", "sketch", "Sketch1", false), + row("s_d1", "sketch", "Sketch1", true), + row("b_len", "feature", "Box1", false), + row("b_h", "feature", "Box1", false), + row("s_d2", "sketch", "Sketch1", false), + ]; + let visible: Vec<&Row> = listed.iter().collect(); + fn names<'a>(rows: &[&'a Row]) -> Vec<&'a str> { + rows.iter().map(|r| r.name.as_str()).collect() + } + let shown = |sort| { + let s = sections(&visible, sort); + (names(&s.favorites), names(&s.users), s.groups.iter().map(|(g, m)| (g.to_string(), names(m))).collect::>()) + }; + let (fav, users, groups) = shown(ui_kit::Sort::Listed); + assert_eq!((fav, users), (vec!["d10", "Alpha", "s_d1"], vec!["d10", "width", "d2", "Alpha", "d1"]), "unsorted: the listed order"); + assert_eq!(groups, [("Sketch1".into(), vec!["s_d3", "s_d1", "s_d2"]), ("Box1".into(), vec!["b_len", "b_h"])]); + let (fav, users, groups) = shown(ui_kit::Sort::Ascending); + assert_eq!(fav, ["Alpha", "d10", "s_d1"]); + assert_eq!(users, ["Alpha", "d1", "d2", "d10", "width"]); + assert_eq!( + groups, + [("Sketch1".into(), vec!["s_d1", "s_d2", "s_d3"]), ("Box1".into(), vec!["b_h", "b_len"])], + "groups keep the design's order" + ); + let (fav, users, groups) = shown(ui_kit::Sort::Descending); + assert_eq!(fav, ["s_d1", "d10", "Alpha"]); + assert_eq!(users, ["width", "d10", "d2", "d1", "Alpha"]); + assert_eq!(groups, [("Sketch1".into(), vec!["s_d3", "s_d2", "s_d1"]), ("Box1".into(), vec!["b_len", "b_h"])]); + } + + /// #8563: clicking a Name heading sorts every table in the window, the choice outlives the + /// window, and moving a user parameter waits for the listed order. + #[test] + fn the_name_heading_sorts_the_window_and_is_remembered() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width": 1600, "height": 1400})); + h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30})).unwrap(); + for name in ["t10", "span", "t2", "Alpha", "t1"] { + h.app.run("parameters.add", json!({"name": name, "expression": "10 mm"})).unwrap(); + } + h.app.start("parameters.change"); + h.frames(6); + let order = |table: &str, names: &[String]| { + let mut rows: Vec<_> = + names.iter().map(|n| (crate::scenario::rect_of(&format!("dialog:table:{table}:{n}")).expect(n).top(), n.clone())).collect(); + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + rows.into_iter().map(|(_, n)| n).collect::>() + }; + let users: Vec = ["t10", "span", "t2", "Alpha", "t1"].map(String::from).to_vec(); + let group = h.app.params.as_ref().unwrap().listed.as_ref().unwrap().1.iter().find(|r| r.source != "user").unwrap().group.clone(); + let model: Vec = + h.app.params.as_ref().unwrap().listed.as_ref().unwrap().1.iter().filter(|r| r.group == group).map(|r| r.name.clone()).collect(); + assert_eq!(order("user", &users), users, "the listed order by default"); + assert!(model.len() > 1, "the box drives several parameters: {model:?}"); + let mut sorted_model = model.clone(); + ui_kit::Sort::Ascending.apply(&mut sorted_model, |s| s.as_str()); + + h.step(&json!({"click": {"handle": "params:sort:user"}})).unwrap(); + h.frames(3); + assert_eq!(order("user", &users), ["Alpha", "span", "t1", "t2", "t10"]); + // The bounded card may not show the model group below the user table: filter to it. + h.app.params.as_mut().unwrap().filter = group.clone(); + h.frames(4); + assert_eq!(order(&group, &model), sorted_model, "a model group sorts too"); + h.app.params.as_mut().unwrap().filter.clear(); + h.frames(4); + assert!(crate::scenario::handle_at("param:after:Alpha").is_some()); + let before = h.app.session.doc.params.iter().map(|p| p.name.clone()).collect::>(); + h.step(&json!({"click": {"handle": "param:after:Alpha"}})).unwrap(); + h.frames(2); + assert_eq!(h.app.session.doc.params.iter().map(|p| p.name.clone()).collect::>(), before, "no move while sorted"); + + // Closed and opened again: still sorted. + h.app.params = None; + h.frames(2); + h.app.start("parameters.change"); + h.frames(4); + assert_eq!(h.app.params.as_ref().unwrap().sort, ui_kit::Sort::Ascending); + h.step(&json!({"click": {"handle": "params:sort:user"}})).unwrap(); + h.frames(3); + assert_eq!(order("user", &users), ["t10", "t2", "t1", "span", "Alpha"]); + h.step(&json!({"click": {"handle": "params:sort:user"}})).unwrap(); + h.frames(3); + assert_eq!(order("user", &users), users, "the third click goes back to the listed order"); + } + /// A design with hundreds of chained parameters. fn big_design() -> crate::scenario::Harness { let mut h = crate::scenario::Harness::new(); @@ -1399,4 +1730,37 @@ mod tests { assert!(crate::scenario::rect_of("dialog:body:viewport").unwrap().height() > 0.0); } } + /// #9358: a formula longer than its column opens wide while it is edited, over the cells to + /// its right, so all of it shows; Enter applies it and the field goes back into its column. + /// A formula that fits stays in its cell. + #[test] + fn a_long_expression_opens_wide_while_edited_and_enter_applies_it() { + let long = "width + width + width + width + width + width + width + width + width + width"; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("parameters.add", json!({"name":"width","expression":"1 mm"})).unwrap(); + h.app.run("parameters.add", json!({"name":"length","expression":long})).unwrap(); + h.step(&json!({"start":"parameters.change"})).unwrap(); + h.frames(3); + let cell = crate::scenario::rect_of("param:length").unwrap(); + assert!(crate::scenario::rect_of("param:width").unwrap().width() <= cell.width() + 0.5); + + h.step(&json!({"click":{"handle":"param:length"}})).unwrap(); + h.frames(2); + let wide = crate::scenario::rect_of("param:length").unwrap(); + assert!(wide.width() > cell.width() * 1.5, "the edited formula opens wide: {wide:?} vs {cell:?}"); + assert!((wide.left() - cell.left()).abs() < 1.0, "it opens from its own cell: {wide:?} vs {cell:?}"); + + // The open field wraps, so the end of the text is Ctrl+End. + h.run(&json!([{"key":"End","cmd":true}, {"text":" * 2"}, {"key":"Enter"}])).unwrap(); + h.frames(2); + assert_eq!(h.app.session.doc.param("length").unwrap().expr, format!("{long} * 2")); + let after = crate::scenario::rect_of("param:length").unwrap(); + assert!(after.width() <= cell.width() + 0.5, "applied, the field is back in its column: {after:?}"); + + h.step(&json!({"click":{"handle":"param:width"}})).unwrap(); + h.frames(2); + let short = crate::scenario::rect_of("param:width").unwrap(); + assert!(short.width() <= cell.width() + 0.5, "a formula that fits stays in its cell: {short:?} vs {cell:?}"); + } } diff --git a/crates/ui-egui/src/personal_sheet_rules.rs b/crates/ui-egui/src/personal_sheet_rules.rs new file mode 100644 index 0000000000..365d942e7e --- /dev/null +++ b/crates/ui-egui/src/personal_sheet_rules.rs @@ -0,0 +1,480 @@ +//! Personal Sheet Metal Rules: rules kept in the user's profile and offered by the Sheet Metal +//! Rules manager of every design (#9346). Picking one only fills the open manager; the manager's +//! OK adds it to the design, or updates the design's rule of that name, as for any rule. The +//! design keeps its own copy, so it builds the same where the profile has no such rule. + +use serde::{Deserialize, Serialize, de}; +use solidcraft_engine::doc::sheet::{AngleDisplay, ReliefShape, SheetRule}; +use solidcraft_engine::doc::{DocError, Document, expr}; +use std::collections::BTreeMap; + +use crate::dialogs::{Dialog, Kind}; +use crate::dialogs_sheet::{ANGLE_DISPLAYS, RELIEF_SHAPES, RULE_FIELDS, Sm, design_rules}; +use crate::i18n::tr; +use crate::ui_kit::{self, Text}; + +const MAX_RULES: usize = 64; +/// The longest rule name `sheet.manage_rules` takes. +const MAX_NAME: usize = 128; +const MAX_EXPRESSION: usize = 512; + +const INVALID_NAME: &str = "sheet.personal_rules.invalid_name"; +const INVALID_VALUES: &str = "sheet.personal_rules.invalid_values"; + +/// A personal rule: a rule's name and values. Every length carries its unit, so the rule gives +/// the same sheet in a millimetre or an inch design; `Thickness` and other expressions are kept +/// as written. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct Rule { + name: String, + thickness: String, + k_factor: String, + bend_radius: String, + relief_width: String, + relief_depth: String, + corner_relief: String, + corner_relief_shape: String, + hem_gap: String, + gap: String, + angle_display: String, + /// Values a newer version saved. They survive renaming and are never dropped silently: a rule + /// that has them can't be recalled or overwritten here. + #[serde(flatten)] + unknown: BTreeMap, +} + +fn valid_text(text: &str, max: usize) -> bool { + text.len() <= max && !text.chars().any(char::is_control) +} + +fn valid_name(name: &str) -> bool { + !name.trim().is_empty() && name == name.trim() && valid_text(name, MAX_NAME) +} + +/// The manager's text for a value, by its parameter name (`dialogs_sheet::RULE_FIELDS`). +fn field<'a>(values: &'a [String], key: &str) -> &'a str { + RULE_FIELDS.iter().position(|(k, _)| *k == key).and_then(|i| values.get(i)).map_or("", |v| v.trim()) +} + +/// A length as the rule keeps it: a bare number takes the design's unit, and an expression +/// without a unit is read in it, as the manager's OK would; anything else is kept as written. +fn portable_length(text: &str, unit: &str, lookup: &dyn Fn(&str) -> Result) -> Result { + let text = text.trim(); + // A definition (`T = 2`) would name a parameter of this design only. + if text.is_empty() || !valid_text(text, MAX_EXPRESSION) || expr::definition(text).is_some() { + return Err(INVALID_VALUES); + } + if expr::is_bare_number(text) { + return Ok(format!("{text} {unit}")); + } + let value = expr::eval_with(text, lookup).map_err(|_| INVALID_VALUES)?; + Ok(if value.unitless() { format!("({text}) * 1 {unit}") } else { text.into() }) +} + +impl Rule { + /// A value by its parameter name, as `dialogs_sheet::RULE_FIELDS` names them. + fn value(&self, key: &str) -> Option<&String> { + Some(match key { + "thickness" => &self.thickness, + "k_factor" => &self.k_factor, + "bend_radius" => &self.bend_radius, + "relief_width" => &self.relief_width, + "relief_depth" => &self.relief_depth, + "corner_relief" => &self.corner_relief, + "hem_gap" => &self.hem_gap, + "gap" => &self.gap, + _ => return None, + }) + } + + fn expressions(&self) -> [&String; 8] { + [&self.thickness, &self.k_factor, &self.bend_radius, &self.relief_width, &self.relief_depth, &self.corner_relief, &self.hem_gap, &self.gap] + } + + fn sheet_rule(&self) -> SheetRule { + SheetRule { + name: self.name.clone(), + thickness: self.thickness.clone(), + k_factor: self.k_factor.clone(), + bend_radius: self.bend_radius.clone(), + relief_width: self.relief_width.clone(), + relief_depth: self.relief_depth.clone(), + corner_relief: self.corner_relief.clone(), + corner_relief_shape: self.corner_relief_shape.clone(), + hem_gap: self.hem_gap.clone(), + gap: self.gap.clone(), + angle_display: self.angle_display.clone(), + } + } + + /// The shape of a stored row. Values are evaluated only in a design (capture and the manager's + /// OK), never when the preferences load. + fn validate(&self) -> Result<(), &'static str> { + if !valid_name(&self.name) { + return Err(INVALID_NAME); + } + if self.expressions().into_iter().any(|text| text.trim().is_empty() || !valid_text(text, MAX_EXPRESSION)) + || ReliefShape::parse(&self.corner_relief_shape).is_none() + || AngleDisplay::parse(&self.angle_display).is_none() + || self.unknown.len() > 8 + || self.unknown.iter().any(|(key, value)| { + key.is_empty() + || !valid_text(key, 64) + || match value { + serde_json::Value::String(text) => !valid_text(text, MAX_EXPRESSION), + serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => false, + _ => true, + } + }) + { + return Err(INVALID_VALUES); + } + Ok(()) + } + + /// The rule the manager shows, under `name`. The design must accept it as the manager's OK + /// would: a positive thickness and a K factor from 0 to 1. + fn capture(sm: &Sm, name: &str, doc: &Document) -> Result { + let Sm::Rules { values, relief, angles, .. } = sm else { + return Err(INVALID_VALUES); + }; + let (vals, _) = doc.param_values(); + let unit = expr::length_unit_or_mm(&doc.units); + let lookup = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))); + let thickness = portable_length(field(values, "thickness"), unit, &lookup)?; + let t = expr::eval_with(&thickness, &lookup).and_then(|v| v.to_kind(expr::Kind::Length)).map_err(|_| INVALID_VALUES)?; + // The other values may use the rule's own thickness, as `rule_values` reads them. + let with_t = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(expr::Value::length(t)) } else { lookup(n) }; + let length = |key: &str| portable_length(field(values, key), unit, &with_t); + let k_factor = field(values, "k_factor"); + if k_factor.is_empty() || !valid_text(k_factor, MAX_EXPRESSION) || expr::definition(k_factor).is_some() { + return Err(INVALID_VALUES); + } + let rule = Self { + name: name.trim().into(), + thickness, + k_factor: k_factor.into(), + bend_radius: length("bend_radius")?, + relief_width: length("relief_width")?, + relief_depth: length("relief_depth")?, + corner_relief: length("corner_relief")?, + corner_relief_shape: RELIEF_SHAPES.get(*relief).copied().unwrap_or("notch").into(), + hem_gap: length("hem_gap")?, + gap: length("gap")?, + angle_display: ANGLE_DISPLAYS.get(*angles).copied().unwrap_or("bend").into(), + unknown: BTreeMap::new(), + }; + rule.validate()?; + doc.rule_values(&vals, &rule.sheet_rule()).map_err(|_| INVALID_VALUES)?; + Ok(rule) + } + + fn same_values(&self, other: &Self) -> bool { + let values = |rule: &Self| { + let mut rule = rule.clone(); + rule.name.clear(); + rule.unknown.clear(); + rule + }; + values(self) == values(other) + } + + /// Fill the manager with this rule: the design's rule of the same name when it has one, else + /// a new rule by this name. Nothing changes in the design until the manager's OK. + fn recall(&self, sm: &mut Sm, doc: &Document) -> Result<(), &'static str> { + self.validate()?; + if !self.unknown.is_empty() { + return Err("sheet.personal_rules.unsupported"); + } + let Sm::Rules { pick, name, values, relief, angles, active, loaded } = sm else { + return Err(INVALID_VALUES); + }; + let rules = design_rules(doc); + *pick = rules.iter().position(|r| r.name == self.name).unwrap_or(rules.len()); + name.clone_from(&self.name); + *values = RULE_FIELDS.iter().map(|(key, _)| self.value(key).cloned().unwrap_or_default()).collect(); + *relief = RELIEF_SHAPES.iter().position(|s| s.eq_ignore_ascii_case(self.corner_relief_shape.trim())).unwrap_or(0); + *angles = ANGLE_DISPLAYS.iter().position(|d| d.eq_ignore_ascii_case(self.angle_display.trim())).unwrap_or(0); + *active = doc.sheet_rule(None).name == self.name || *pick >= rules.len(); + // The manager loads a rule's values only when its Rule box changes: these are now its own. + *loaded = Some(*pick); + Ok(()) + } +} + +/// Stored as one preference array. A malformed row cannot discard unrelated preferences or valid +/// rows. The rows and every user-controlled string are bounded. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(transparent)] +pub struct Library(Vec); + +impl<'de> Deserialize<'de> for Library { + fn deserialize>(deserializer: D) -> Result { + struct LibraryVisitor; + impl<'de> de::Visitor<'de> for LibraryVisitor { + type Value = Library; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an array of personal sheet metal rules") + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut library = Library::default(); + for _ in 0..MAX_RULES { + let Some(row) = seq.next_element::()? else { return Ok(library) }; + if let Ok(rule) = serde_json::from_value::(row) + && rule.validate().is_ok() + && library.find(&rule.name).is_none() + { + library.0.push(rule); + } + } + while seq.next_element::()?.is_some() {} + Ok(library) + } + } + deserializer.deserialize_seq(LibraryVisitor) + } +} + +impl Library { + /// Names are unique ignoring case. + fn find(&self, name: &str) -> Option { + let name = name.to_lowercase(); + self.0.iter().position(|rule| rule.name.to_lowercase() == name) + } + + fn save(&mut self, sm: &Sm, name: &str, doc: &Document) -> Result<(), &'static str> { + if self.find(name.trim()).is_some() { + return Err("sheet.personal_rules.name_exists"); + } + if self.0.len() >= MAX_RULES { + return Err("sheet.personal_rules.full"); + } + self.0.push(Rule::capture(sm, name, doc)?); + Ok(()) + } + + fn modified(&self, selected: &str, sm: &Sm, doc: &Document) -> bool { + self.find(selected).and_then(|index| self.0.get(index)).is_some_and(|saved| match Rule::capture(sm, &saved.name, doc) { + Ok(current) => !current.same_values(saved), + Err(_) => true, + }) + } + + fn overwrite(&mut self, selected: &str, sm: &Sm, doc: &Document) -> Result<(), &'static str> { + let index = self.find(selected).ok_or("sheet.personal_rules.missing")?; + let saved = self.0.get(index).ok_or("sheet.personal_rules.missing")?; + if !saved.unknown.is_empty() { + return Err("sheet.personal_rules.unsupported"); + } + let candidate = Rule::capture(sm, &saved.name, doc)?; + // Capture and its checks finish before the saved rule is replaced. + *self.0.get_mut(index).ok_or("sheet.personal_rules.missing")? = candidate; + Ok(()) + } + + fn rename(&mut self, old: &str, new: &str) -> Result<(), &'static str> { + let new = new.trim(); + if !valid_name(new) { + return Err(INVALID_NAME); + } + let index = self.find(old).ok_or("sheet.personal_rules.missing")?; + if self.find(new).is_some_and(|other| other != index) { + return Err("sheet.personal_rules.name_exists"); + } + let rule = self.0.get_mut(index).ok_or("sheet.personal_rules.missing")?; + rule.name = new.into(); + Ok(()) + } +} + +/// The name of the rule the manager shows: the design's rule picked, or the new rule's name. +fn shown_name(sm: &Sm, doc: &Document) -> String { + let Sm::Rules { pick, name, .. } = sm else { return String::new() }; + design_rules(doc).get(*pick).map_or_else(|| name.trim().to_string(), |rule| rule.name.clone()) +} + +#[derive(Clone)] +struct NamePrompt { + name: String, + renaming: bool, +} + +#[derive(Clone, Default)] +struct PanelState { + selected: String, + prompt: Option, + confirming_delete: bool, + error: Option<&'static str>, +} + +fn panel_id() -> egui::Id { + egui::Id::new("personal_sheet_rules_panel") +} + +fn rule_manager(d: &mut Dialog) -> Option<&mut Sm> { + match &mut d.kind { + Kind::Sheet(sm @ Sm::Rules { .. }) => Some(sm), + _ => None, + } +} + +/// One section above the Sheet Metal Rules manager's fields. Only the library is kept in the +/// preferences; the selection, errors and the naming prompt belong to this dialog. +pub(crate) fn rows(ui: &mut egui::Ui, d: &mut Dialog, library: &mut Library, doc: &Document, reset: bool) { + let Some(sm) = rule_manager(d) else { return }; + let id = panel_id(); + let mut state = if reset { PanelState::default() } else { ui.ctx().data_mut(|data| data.get_temp::(id)).unwrap_or_default() }; + ui_kit::section(ui, "personal_sheet_rules", "sheet.personal_rules.title", false, |ui| { + ui.label(tr("sheet.personal_rules.scope")); + crate::dialogs::form_row(ui, "sheet.personal_rules.rule", |ui| { + let before = library.find(&state.selected).map_or(0, |index| index + 1); + let mut selected = before; + let dirty = library.modified(&state.selected, sm, doc); + let display = library.find(&state.selected).and_then(|index| library.0.get(index)).map_or_else( + || tr("sheet.personal_rules.select").to_string(), + |rule| { + if dirty { format!("* {}", rule.name) } else { rule.name.clone() } + }, + ); + let height = ui_kit::row_height(); + let width = (ui.available_width() - height - ui.spacing().item_spacing.x).max(1.0); + ui.allocate_ui_with_layout(egui::vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + let response = ui_kit::dropdown_menu(ui, "personal_sheet_rule_choice", &display, |ui| { + let placeholder = ui_kit::list_picker(ui, "personal_sheet_rules_select", tr("sheet.personal_rules.select"), selected == 0); + if placeholder.clicked() { + selected = 0; + } + crate::scenario::publish_handle( + &format!("dialog:sheet.personal_rules.rule:{}", tr("sheet.personal_rules.select")), + placeholder.rect.center(), + ); + for (index, rule) in library.0.iter().enumerate() { + let option = ui_kit::list_picker(ui, &format!("personal_sheet_rule_{index}"), &rule.name, selected == index + 1); + if option.clicked() { + selected = index + 1; + } + crate::scenario::publish_handle(&format!("dialog:sheet.personal_rules.rule:{}", rule.name), option.rect.center()); + } + }); + response.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, ui.is_enabled(), tr("sheet.personal_rules.rule")); + info.current_text_value = Some(display.clone()); + info + }); + }); + if selected != before { + let rule = selected.checked_sub(1).and_then(|index| library.0.get(index)); + state.selected = rule.map_or_else(String::new, |rule| rule.name.clone()); + state.error = rule.and_then(|rule| rule.recall(sm, doc).err()); + state.prompt = None; + state.confirming_delete = false; + } + let modified = library.modified(&state.selected, sm, doc); + ui.add_enabled_ui(modified, |ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(height, height), egui::Sense::hover()); + let save = ui_kit::icon_button_at(ui, rect, "save", tr("sheet.personal_rules.overwrite")); + crate::scenario::publish_target("dialog:sheet.personal_rules:overwrite", save.rect); + if save.clicked() { + state.error = library.overwrite(&state.selected, sm, doc).err(); + state.confirming_delete = false; + } + }); + }); + let chosen = library.find(&state.selected); + // Each button is also a handle (`dialog:sheet.personal_rules:`) for scenarios and the + // control channel. + let button = |ui: &mut egui::Ui, action: &str, label: &str, enabled: bool| { + let response = ui_kit::button(ui, label, ui_kit::Button::Enabled(enabled)); + crate::scenario::publish_handle(&format!("dialog:sheet.personal_rules:{action}"), response.rect.center()); + response.clicked() + }; + ui.horizontal_wrapped(|ui| { + if button(ui, "save", tr("sheet.personal_rules.save"), true) { + state.prompt = Some(NamePrompt { name: shown_name(sm, doc), renaming: false }); + state.error = None; + state.confirming_delete = false; + } + if button(ui, "rename", tr("sheet.personal_rules.rename"), chosen.is_some()) { + state.prompt = Some(NamePrompt { name: state.selected.clone(), renaming: true }); + state.error = None; + state.confirming_delete = false; + } + if button(ui, "delete", tr("sheet.personal_rules.delete"), chosen.is_some()) { + state.confirming_delete = true; + state.error = None; + } + }); + if state.confirming_delete { + ui.label(tr("sheet.personal_rules.delete_prompt")); + ui.horizontal_wrapped(|ui| { + if button(ui, "confirm_delete", tr("sheet.personal_rules.confirm_delete"), true) { + if let Some(index) = library.find(&state.selected) { + library.0.remove(index); + } + state = PanelState::default(); + } + if ui_kit::button(ui, tr("ui.cancel"), ui_kit::Button::Normal).clicked() { + state.confirming_delete = false; + } + }); + } + if state.prompt.is_none() + && let Some(error) = state.error + { + ui_kit::text(ui, tr(error), Text::Report("personal_sheet_rules_error", true)); + } + }); + ui.ctx().data_mut(|data| data.insert_temp(id, state)); +} + +/// Global shortcuts run before dialogs. A pending name prompt owns their keyboard even when +/// Escape has already left its text field. Stale state cannot block other tools. +pub(crate) fn name_prompt_open(ctx: &egui::Context, dialog: Option<&Dialog>) -> bool { + dialog.is_some_and(|d| matches!(&d.kind, Kind::Sheet(Sm::Rules { .. }))) + && ctx.data_mut(|data| data.get_temp::(panel_id())).is_some_and(|state| state.prompt.is_some()) +} + +/// Drawn after the manager's card, so the prompt owns input and its footer handles. `true` also +/// covers the prompt's frame: its Enter and Cancel never reach the manager. +pub(crate) fn name_prompt(ctx: &egui::Context, d: &mut Dialog, library: &mut Library, doc: &Document) -> bool { + let Some(sm) = rule_manager(d) else { return false }; + let id = panel_id(); + let mut state = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or_default(); + let Some(mut prompt) = state.prompt.take() else { return false }; + let title = tr(if prompt.renaming { "sheet.personal_rules.rename" } else { "sheet.personal_rules.save" }); + let (closed, action) = ui_kit::Confirm::new(egui::Id::new("personal_sheet_rule_name"), "save", title) + .actions(if prompt.renaming { tr("sheet.personal_rules.rename") } else { crate::i18n::tr_label("Save") }, "Cancel", None) + .show(ctx, true, |ui| { + ui_kit::row(ui, "sheet.personal_rules.name", |ui| ui_kit::focused_text_field(ui, &mut prompt.name, tr("sheet.personal_rules.name_hint"))); + // Keep an over-long name for a clear error, but bound what the prompt holds. + if prompt.name.len() > MAX_EXPRESSION { + let end = prompt.name.char_indices().map(|(index, _)| index).take_while(|index| *index <= MAX_EXPRESSION).last().unwrap_or(0); + prompt.name.truncate(end); + } + if let Some(error) = state.error { + ui_kit::text(ui, tr(error), Text::Report("personal_sheet_rules_name_error", true)); + } + }); + ctx.input_mut(|input| { + input.consume_key(egui::Modifiers::NONE, egui::Key::Enter); + input.consume_key(egui::Modifiers::NONE, egui::Key::Escape); + }); + if closed || action == ui_kit::Action::Cancel { + state.error = None; + } else if action == ui_kit::Action::Ok { + state.error = if prompt.renaming { library.rename(&state.selected, &prompt.name).err() } else { library.save(sm, &prompt.name, doc).err() }; + if state.error.is_none() { + state.selected = prompt.name.trim().into(); + } else { + state.prompt = Some(prompt); + } + } else { + state.prompt = Some(prompt); + } + ctx.data_mut(|data| data.insert_temp(id, state)); + true +} + +#[cfg(test)] +#[path = "personal_sheet_rules_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/personal_sheet_rules_tests.rs b/crates/ui-egui/src/personal_sheet_rules_tests.rs new file mode 100644 index 0000000000..f5bd7f5736 --- /dev/null +++ b/crates/ui-egui/src/personal_sheet_rules_tests.rs @@ -0,0 +1,241 @@ +//! Personal Sheet Metal Rules (#9346): saved from one design's rule manager, offered by every +//! design's, and added to a design only by the manager's OK. + +use serde_json::{Value, json}; + +use super::{INVALID_VALUES, Library, Rule}; +use crate::dialogs::Kind; +use crate::dialogs_sheet::Sm; +use crate::scenario::Harness; + +/// A design in `units` with the Sheet Metal Rules manager open and its Personal rules section +/// unfolded. +fn manager(units: &str) -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + if units != "mm" { + h.app.run("document.units", json!({ "units": units })).unwrap(); + } + h.app.start("sheet.manage_rules"); + h.frames(4); + h.step(&json!({"click":{"handle":"dialog:section:sheet.personal_rules.title"}})).unwrap(); + h.frames(2); + h +} + +/// Another design whose profile has `library`. +fn manager_with(units: &str, library: &Library) -> Harness { + let mut h = manager(units); + h.app.preferences.personal_sheet_rules = library.clone(); + h.frames(2); + h +} + +fn sm(h: &mut Harness) -> &mut Sm { + match &mut h.app.dialog.as_mut().expect("the rule manager is open").kind { + Kind::Sheet(sm @ Sm::Rules { .. }) => sm, + _ => panic!("expected the Sheet Metal Rules manager"), + } +} + +/// Type the manager's values in its field order: thickness, K factor, bend radius, relief width, +/// relief depth, corner relief, hem gap and flange gap. +fn type_values(h: &mut Harness, typed: [&str; 8]) { + let Sm::Rules { values, .. } = sm(h) else { panic!() }; + *values = typed.iter().map(|v| v.to_string()).collect(); + h.frames(2); +} + +fn save_personal(h: &mut Harness, name: &str) { + h.step(&json!({"widget":"Save new"})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules.name"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":name})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + h.frames(2); +} + +fn choose_personal(h: &mut Harness, name: &str) { + h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules.rule"}})).unwrap(); + h.step(&json!({"click":{"handle":format!("dialog:sheet.personal_rules.rule:{name}")}})).unwrap(); + h.frames(2); +} + +/// What the manager's OK runs now. +fn ok_commands(h: &Harness) -> Vec<(String, Value)> { + crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap() +} + +fn library_json(h: &Harness) -> Value { + serde_json::to_value(&h.app.preferences.personal_sheet_rules).unwrap() +} + +fn row(name: &str) -> Value { + json!({"name":name,"thickness":"2 mm","k_factor":"0.44","bend_radius":"Thickness","relief_width":"Thickness", + "relief_depth":"Thickness * 0.5","corner_relief":"Thickness * 4.0","corner_relief_shape":"notch","hem_gap":"0.01 mm", + "gap":"Thickness","angle_display":"bend"}) +} + +#[test] +fn a_personal_rule_saved_in_one_design_is_added_to_another_by_ok() { + let mut h = manager("mm"); + type_values(&mut h, ["1.6", "0.42", "1.6", "Thickness", "Thickness * 0.5", "Thickness * 4.0", "0.01", "Thickness"]); + save_personal(&mut h, "CRS 1.6"); + // Bare numbers are saved with the design's unit; expressions as written. + assert_eq!( + library_json(&h), + json!([{"name":"CRS 1.6","thickness":"1.6 mm","k_factor":"0.42","bend_radius":"1.6 mm","relief_width":"Thickness", + "relief_depth":"Thickness * 0.5","corner_relief":"Thickness * 4.0","corner_relief_shape":"notch","hem_gap":"0.01 mm", + "gap":"Thickness","angle_display":"bend"}]) + ); + assert!(h.app.session.doc.sheet.rules.is_empty(), "saving a personal rule is a preference, not a design change"); + + let mut other = manager_with("mm", &h.app.preferences.personal_sheet_rules); + let before = serde_json::to_value(&*other.app.session.doc).unwrap(); + choose_personal(&mut other, "CRS 1.6"); + assert_eq!(serde_json::to_value(&*other.app.session.doc).unwrap(), before, "picking a personal rule changes nothing in the design"); + assert_eq!( + ok_commands(&other), + vec![( + "sheet.manage_rules".to_string(), + json!({"name":"CRS 1.6","active":true,"thickness":"1.6 mm","k_factor":"0.42","bend_radius":"1.6 mm","relief_width":"Thickness", + "relief_depth":"Thickness * 0.5","corner_relief":"Thickness * 4.0","hem_gap":"0.01 mm","gap":"Thickness", + "corner_relief_shape":"notch","angle_display":"bend"}) + )] + ); + other.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + other.frames(4); + let doc = &other.app.session.doc; + assert_eq!(doc.sheet.active.as_deref(), Some("CRS 1.6")); + let rule = doc.sheet.rules.iter().find(|r| r.name == "CRS 1.6").expect("OK adds the rule to the design"); + assert_eq!((rule.thickness.as_str(), rule.k_factor.as_str(), rule.bend_radius.as_str()), ("1.6 mm", "0.42", "1.6 mm")); +} + +#[test] +fn a_personal_rule_keeps_its_size_between_inch_and_millimetre_designs() { + let mut h = manager("in"); + type_values(&mut h, ["0.0625", "0.44", "Thickness", "Thickness", "Thickness * 0.5", "(1 + 1) / 8", "0.01", "Thickness"]); + save_personal(&mut h, "16 ga"); + let saved = library_json(&h); + assert_eq!(saved[0]["thickness"], "0.0625 in"); + assert_eq!(saved[0]["corner_relief"], "((1 + 1) / 8) * 1 in", "an expression without a unit is read in the design's"); + assert_eq!(saved[0]["bend_radius"], "Thickness"); + + let mut mm = manager_with("mm", &h.app.preferences.personal_sheet_rules); + choose_personal(&mut mm, "16 ga"); + mm.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + mm.frames(4); + let doc = &mm.app.session.doc; + let (vals, _) = doc.param_values(); + let values = doc.rule_values(&vals, &doc.sheet_rule(Some("16 ga"))).unwrap(); + // 1/16 in and 1/4 in, whatever the design's unit. + assert!((values.thickness - 1.5875).abs() < 1e-9, "thickness {}", values.thickness); + assert!((values.bend_radius - 1.5875).abs() < 1e-9, "bend radius {}", values.bend_radius); + assert!((values.corner_relief - 6.35).abs() < 1e-9, "corner relief {}", values.corner_relief); +} + +#[test] +fn a_personal_rule_updates_the_design_rule_of_the_same_name() { + let mut h = manager("mm"); + type_values(&mut h, ["3", "0.4", "4.5", "Thickness", "Thickness * 0.5", "Thickness * 4.0", "0.01", "Thickness"]); + save_personal(&mut h, "Shop 3 mm"); + h.app.dialog = None; + h.frames(2); + h.app.session.execute("sheet.manage_rules", &json!({"name":"Shop 3 mm","thickness":"2 mm","k_factor":0.5,"active":true})).unwrap(); + h.app.session.execute("sheet.manage_rules", &json!({"name":"Other","thickness":"1 mm","active":true})).unwrap(); + h.app.start("sheet.manage_rules"); + h.frames(4); + choose_personal(&mut h, "Shop 3 mm"); + let Sm::Rules { pick, active, .. } = sm(&mut h) else { panic!() }; + assert_eq!((*pick, *active), (0, false), "the design's own rule of that name, still not its active rule"); + let commands = ok_commands(&h); + assert_eq!( + [&commands[0].1["name"], &commands[0].1["thickness"], &commands[0].1["k_factor"], &commands[0].1["bend_radius"], &commands[0].1["active"]], + [&json!("Shop 3 mm"), &json!("3 mm"), &json!("0.4"), &json!("4.5 mm"), &json!(false)] + ); + h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + h.frames(4); + let doc = &h.app.session.doc; + assert_eq!(doc.sheet.rules.len(), 2, "updated in place, not added again"); + assert_eq!(doc.sheet_rule(Some("Shop 3 mm")).thickness, "3 mm"); + assert_eq!(doc.sheet.active.as_deref(), Some("Other")); +} + +#[test] +fn the_save_icon_rename_and_delete_change_only_the_personal_library() { + let mut h = manager("mm"); + save_personal(&mut h, "Steel 2.5"); + assert_eq!(library_json(&h)[0]["thickness"], "2.5 mm"); + let Sm::Rules { values, .. } = sm(&mut h) else { panic!() }; + values[0] = "3 mm".into(); + h.frames(2); + h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules:overwrite"}})).unwrap(); + h.frames(2); + assert_eq!(library_json(&h)[0]["thickness"], "3 mm", "the save icon overwrites the selected personal rule"); + h.step(&json!({"widget":"Rename"})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules.name"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"Steel 3"})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + h.frames(2); + assert_eq!(library_json(&h)[0]["name"], "Steel 3"); + h.step(&json!({"widget":"Delete"})).unwrap(); + h.frames(1); + h.step(&json!({"widget":"Confirm delete"})).unwrap(); + h.frames(2); + assert_eq!(library_json(&h), json!([])); + assert!(h.app.session.doc.sheet.rules.is_empty(), "the design never changed"); +} + +#[test] +fn saving_refuses_a_rule_the_design_could_not_use() { + let mut h = manager("mm"); + let doc = h.app.session.doc.clone(); + // A K factor past 1, an empty thickness, a definition and an unknown parameter. + for (index, text) in [(1, "1.5"), (0, ""), (0, "T = 2"), (2, "Missing * 2")] { + let Sm::Rules { values, .. } = sm(&mut h) else { panic!() }; + let kept = std::mem::replace(&mut values[index], text.into()); + assert_eq!(Rule::capture(sm(&mut h), "Bad", &doc), Err(INVALID_VALUES), "{text:?}"); + let Sm::Rules { values, .. } = sm(&mut h) else { panic!() }; + values[index] = kept; + } + assert!(Rule::capture(sm(&mut h), "Good", &doc).is_ok()); + let Sm::Rules { values, .. } = sm(&mut h) else { panic!() }; + values[1] = "1.5".into(); + h.frames(1); + save_personal(&mut h, "Bad K"); + assert_eq!(library_json(&h), json!([]), "nothing is saved while the prompt reports the error"); + assert!(personal_sheet_rules_prompt_open(&h)); +} + +fn personal_sheet_rules_prompt_open(h: &Harness) -> bool { + super::name_prompt_open(&h.ctx, h.app.dialog.as_ref()) +} + +#[test] +fn malformed_rows_are_dropped_and_a_newer_versions_values_are_kept_but_never_recalled() { + let mut bad_shape = row("Bad shape"); + bad_shape["corner_relief_shape"] = json!("hexagon"); + let mut empty = row("Empty"); + empty["thickness"] = json!(" "); + let mut newer = row("Newer"); + newer["unfold"] = json!("bend table"); + let rows = json!([null, row("Shop"), bad_shape, empty, row(" padded "), row("SHOP"), {"name":"incomplete"}, newer]); + let mut library: Library = serde_json::from_value(rows).unwrap(); + assert_eq!(library.0.iter().map(|r| r.name.as_str()).collect::>(), ["Shop", "Newer"]); + assert_eq!(serde_json::to_value(&library).unwrap()[1]["unfold"], "bend table", "a newer version's value survives"); + let many: Vec<_> = (0..100).map(|i| row(&format!("Rule {i}"))).collect(); + assert_eq!(serde_json::from_value::(json!(many)).unwrap().0.len(), 64); + library.rename("Newer", "Newer rule").unwrap(); + assert_eq!(serde_json::to_value(&library).unwrap()[1]["unfold"], "bend table", "renaming keeps it too"); + assert_eq!(library.rename("Newer rule", "shop"), Err("sheet.personal_rules.name_exists")); + assert_eq!(library.rename("Newer rule", " "), Err("sheet.personal_rules.invalid_name")); + + let mut h = manager("mm"); + let doc = h.app.session.doc.clone(); + let before = format!("{:?}", sm(&mut h)); + let newer = library.0[1].clone(); + assert_eq!(newer.recall(sm(&mut h), &doc), Err("sheet.personal_rules.unsupported")); + assert_eq!(format!("{:?}", sm(&mut h)), before, "a refused recall leaves the manager as it was"); + assert_eq!(library.overwrite("Newer rule", sm(&mut h), &doc), Err("sheet.personal_rules.unsupported")); +} diff --git a/crates/ui-egui/src/point_clouds.rs b/crates/ui-egui/src/point_clouds.rs index 414c33760c..9519670949 100644 --- a/crates/ui-egui/src/point_clouds.rs +++ b/crates/ui-egui/src/point_clouds.rs @@ -1,90 +1,191 @@ -//! Point clouds (#5151): with the GPU viewport their points are dots in the 3D pass (depth-tested -//! with the model); the CPU viewport draws some of them over its image. +//! Point clouds (#5151): with the GPU viewport their points are drawn in the 3D pass +//! (depth-tested with the model), coloured and filtered by the shaders; the CPU viewport draws +//! some of them over its image. use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use egui::{Painter, Shape}; -use solidcraft_engine::doc::point_cloud::PointCloud; +use solidcraft_engine::doc::point_cloud::Keep; +use solidcraft_engine::doc::point_cloud::{ColorBy, PLAIN, PointCloud, Source, byte}; +use solidcraft_engine::doc::point_cloud_distribution::color_range_by; +use solidcraft_gpu::view::{CloudFilter, CloudStyle, ColorMode, Dot, FILTERS, GpuCloud, cloud_dot}; use crate::SolveApp; -use crate::gpu::GpuPoints; +use crate::browser::point_cloud_tracks::{Ask, Paint, Tracks}; use crate::viewport::Proj; /// Most points the CPU viewport draws of all clouds together. const MAX_CPU_POINTS: usize = 100_000; -/// What a cloud's dots are made from: they are made again only when one of these changes. +/// What a cloud's records are made from: they are made again only when one of these changes. +/// Its colour range, filter ranges and dot size are its style, read by the shaders. #[derive(Clone, PartialEq)] struct Key { - points: usize, - colors: usize, + data: Vec, len: usize, origin: [u64; 3], transform: [u64; 16], - size: u64, - color_by: solidcraft_engine::doc::point_cloud::ColorBy, + color_by: ColorBy, color: Option<[u32; 3]>, - height_range: Option<[u64; 2]>, + value: Option, + filtered: [Option; FILTERS], } impl Key { fn of(c: &PointCloud) -> Key { + let mut data = vec![Arc::as_ptr(c.points.values()).cast::() as usize, Arc::as_ptr(&c.colors.0).cast::() as usize]; + data.extend(c.complex.columns.iter().map(|k| Arc::as_ptr(&k.values.0).cast::() as usize)); Key { - points: Arc::as_ptr(c.points.values()).cast::() as usize, - colors: Arc::as_ptr(&c.colors.0).cast::() as usize, + data, len: c.len(), origin: [c.origin.x, c.origin.y, c.origin.z].map(f64::to_bits), transform: std::array::from_fn(|i| c.transform[i / 4][i % 4].to_bits()), - size: c.point_size.to_bits(), color_by: c.color_by, color: c.color.map(|k| k.map(f32::to_bits)), - height_range: c.height_range.map(|r| r.map(f64::to_bits)), + value: c.colored_property().map(str::to_string), + filtered: std::array::from_fn(|k| c.filters.get(k).map(|f| f.property.clone())), } } } thread_local! { - /// Dots by cloud id: what they were made from, their version and bytes. - static DOTS: RefCell>)>> = RefCell::new(HashMap::new()); + /// Records by cloud id: what they were made from, their version and bytes. + static RECORDS: RefCell>)>> = RefCell::new(HashMap::new()); static VERSION: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// By cloud id, the values its coloured property takes over what is kept, worked out off the + /// frame (one worker a cloud, so that each keeps its own geometry). + static EXTENTS: RefCell> = RefCell::new(HashMap::new()); } -fn dots(c: &PointCloud) -> Vec { - let mut out = Vec::with_capacity(c.len() * crate::gpu::CLOUD_POINT_SIZE); - let size = c.point_size as f32; - let shade = c.shade(); - for (i, p) in c.points.iter().enumerate() { +/// The ramp's range of `c` as drawn ([`as_drawn`]): the one set, else, by a property, exactly +/// the values it takes over what the drawn filters keep. Worked out off the frame: until then, +/// the last answer, or the cloud's own bound. +pub(crate) fn color_range(ctx: &egui::Context, c: &PointCloud) -> Option<[f64; 2]> { + color_range_by(c, |property, axis| { + let ask = Ask { + property: property.to_string(), + span: axis, + loupe: axis, + bins: 1, + paint: Paint::One, + filters: c.filters.iter().take(FILTERS).cloned().collect(), + changes_of: None, + }; + EXTENTS.with(|e| e.borrow_mut().entry(c.id).or_default().answer(ctx, c, "extent", ask)).and_then(|a| a.extent) + }) +} + +fn records(c: &PointCloud) -> Arc> { + let plain = PLAIN.map(byte); + let single = c.color.unwrap_or(PLAIN).map(byte); + let own = |i: usize| match c.color_by { + ColorBy::Single => single, + ColorBy::File => c.colors.get(i).unwrap_or(plain), + ColorBy::Height | ColorBy::Property => plain, + }; + // The coloured property, then the filtered ones. + let sources: [Option; 1 + FILTERS] = std::array::from_fn(|k| { + let name = if k == 0 { c.colored_property() } else { c.filters.get(k - 1).map(|f| f.property.as_str()) }; + name.and_then(|n| c.source(n)) + }); + let mut out = Vec::with_capacity(c.len() * solidcraft_gpu::view::DOT_SIZE); + for i in 0..c.len() { // Placed in double precision: the cloud's origin may be far from the design's. - let local = c.origin + solidcraft_engine::geom::Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2])); - let w = solidcraft_engine::doc::apply_point(&c.transform, local); - crate::gpu::cloud_point(&mut out, [w.x as f32, w.y as f32, w.z as f32], shade(i), size); + let w = c.world_point(i).unwrap_or_default(); + let at = [w.x as f32, w.y as f32, w.z as f32]; + let values = sources.map(|s| { + s.map(|s| match s { + Source::Coordinate(k) => at.get(k).copied().unwrap_or(0.0), + Source::Column(v) => v.get(i).copied().unwrap_or(0.0), + }) + }); + let mut dot = Dot { at, rgb: own(i), ..Default::default() }; + let [value, filtered @ ..] = values; + if let Some(v) = value { + dot.value = v; + dot.has |= 1 << FILTERS; + } + for (k, f) in filtered.into_iter().enumerate() { + if let (Some(f), Some(slot)) = (f, dot.filtered.get_mut(k)) { + *slot = f; + dot.has |= 1 << k; + } + } + cloud_dot(&mut out, &dot); } - out + Arc::new(out) } -/// The shown point clouds as GPU dots. -pub fn gpu_points(app: &SolveApp) -> Vec { +/// The cloud as it is drawn now: a filter `playing` in the Edit Point Cloud card at its range +/// now, so that what follows from the filters (the ramp's automatic range) follows it too. +pub(crate) fn as_drawn(c: &PointCloud, playing: impl Fn(usize) -> Option<[f64; 2]>) -> std::borrow::Cow<'_, PointCloud> { + let mut c = std::borrow::Cow::Borrowed(c); + for k in 0..c.filters.len() { + if let Some(range) = playing(k) + && let Some(f) = c.to_mut().filters.get_mut(k) + { + f.keep = Keep::Range { range }; + } + } + c +} + +/// How a cloud is coloured and which of its elements are shown, as the shaders read it; a filter +/// playing in the Edit Point Cloud card shows its range now. +fn style(ctx: &egui::Context, c: &PointCloud, playing: impl Fn(usize) -> Option<[f64; 2]>) -> CloudStyle { + let c = as_drawn(c, playing); + let property = c.colored_property(); + let mode = match (c.color_by, property) { + (ColorBy::File | ColorBy::Single, _) | (_, None) => ColorMode::Own, + (_, Some(name)) if c.complex.integer(name) => ColorMode::Palette, + _ => ColorMode::Ramp, + }; + let mut filters = [CloudFilter::Off; FILTERS]; + for (f, slot) in c.filters.iter().zip(filters.iter_mut()) { + *slot = match &f.keep { + Keep::Range { range } => CloudFilter::Range(range.map(|v| v as f32)), + Keep::Values { values } => { + // Bit `i` is the value `offset + i`, from the label's smallest value. + let offset = c.complex.names(&f.property).and_then(|n| n.values.iter().map(|(v, _)| *v).min()).unwrap_or(0); + let mask = values + .iter() + .filter_map(|v| u32::try_from(i64::from(*v) - i64::from(offset)).ok()) + .filter(|i| *i < 32) + .fold(0u32, |m, i| m | 1 << i); + CloudFilter::Values { offset, mask } + } + }; + } + let range = color_range(ctx, &c).unwrap_or([0.0, 1.0]).map(|v| v as f32); + CloudStyle { mode, range, filters, point_size: c.point_size as f32 } +} + +/// The shown point clouds as the GPU draws them. +pub fn gpu_clouds(app: &SolveApp, ctx: &egui::Context) -> Vec { let doc = &app.session.doc; - DOTS.with(|m| { + EXTENTS.with(|e| e.borrow_mut().retain(|id, _| doc.point_clouds.iter().any(|c| c.id == *id))); + RECORDS.with(|m| { let mut m = m.borrow_mut(); m.retain(|id, _| doc.point_clouds.iter().any(|c| c.id == *id)); solidcraft_engine::view::shown_point_clouds(&app.session) .map(|c| { let key = Key::of(c); - if let Some((k, version, bytes)) = m.get(&c.id) - && *k == key - { - return GpuPoints { id: c.id, version: *version, bytes: bytes.clone() }; - } - let version = VERSION.with(|v| { - v.set(v.get() + 1); - v.get() - }); - let bytes = Arc::new(dots(c)); - m.insert(c.id, (key, version, bytes.clone())); - GpuPoints { id: c.id, version, bytes } + let (version, dots) = match m.get(&c.id) { + Some((k, version, records)) if *k == key => (*version, records.clone()), + _ => { + let version = VERSION.with(|v| { + v.set(v.get() + 1); + v.get() + }); + let records = records(c); + m.insert(c.id, (key, version, records.clone())); + (version, records) + } + }; + let playing = |k: usize| app.tree.cloud_panel.as_ref().and_then(|p| p.playing(c.id, k)); + GpuCloud { id: c.id, version, dots, style: style(ctx, c, playing) } }) .collect() }) @@ -104,7 +205,7 @@ pub fn show(app: &SolveApp, painter: &Painter, proj: &Proj) { let mut shapes = Vec::new(); for c in clouds { let r = (c.point_size as f32 / painter.ctx().pixels_per_point() * 0.5).max(0.5); - let shade = c.shade(); + let shade = c.shade(color_range(painter.ctx(), c)); for i in (0..c.len()).step_by(stride) { let Some(p) = c.world_point(i) else { continue }; let Some(q) = proj.to_screen(p) else { continue }; @@ -119,7 +220,7 @@ mod tests { use super::*; #[test] - fn dots_follow_the_cloud_and_are_made_again_only_when_it_changes() { + fn records_are_made_again_only_when_the_cloud_changes_and_its_style_follows_it() { let mut h = crate::scenario::Harness::new(); let dir = std::env::temp_dir().join(format!("solidcraft-ui-cloud-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -131,32 +232,68 @@ mod tests { assert!(!crate::browser::is_open(&h.app, "point_clouds", true)); h.app.run("browser.expand_all", serde_json::json!({})).unwrap(); assert!(crate::browser::is_open(&h.app, "point_clouds", true)); - let first = gpu_points(&h.app); + let first = gpu_clouds(&h.app, &h.ctx); assert_eq!(first.len(), 1); - assert_eq!(first[0].bytes.len(), 2 * crate::gpu::CLOUD_POINT_SIZE); - // The second point: at x = 10, green, 3 px (24 eighths). - assert_eq!(&first[0].bytes[16..20], &10f32.to_le_bytes()); - assert_eq!(&first[0].bytes[28..32], &[0, 255, 0, 24]); - assert_eq!(gpu_points(&h.app)[0].version, first[0].version, "unchanged: the same dots"); + let size = solidcraft_gpu::view::DOT_SIZE; + assert_eq!(first[0].dots.len(), 2 * size); + // The second point: at x = 10, green. + assert_eq!(&first[0].dots[size..size + 4], &10f32.to_le_bytes()); + assert_eq!(&first[0].dots[2 * size - 4..2 * size - 1], &[0, 255, 0]); + assert_eq!(first[0].style.point_size, 3.0); + assert_eq!(gpu_clouds(&h.app, &h.ctx)[0].version, first[0].version, "unchanged: the same records"); h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color": [0, 0, 1]})).unwrap(); - let blue = gpu_points(&h.app); + let blue = gpu_clouds(&h.app, &h.ctx); assert_ne!(blue[0].version, first[0].version); - assert_eq!(&blue[0].bytes[12..15], &[0, 0, 255]); - // By height across x = 0…10 (the cloud turned so x is up): low end blue, high end red. - let up = serde_json::json!([0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0]); - h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "height", "matrix": up})).unwrap(); - let ramp = gpu_points(&h.app); - let ramp_of = solidcraft_engine::doc::point_cloud::height_ramp; - assert_eq!((&ramp[0].bytes[12..15], &ramp[0].bytes[28..31]), (&ramp_of(0.0)[..], &ramp_of(1.0)[..])); + assert_eq!(&blue[0].dots[size - 4..size - 1], &[0, 0, 255]); + // By height: a ramp over the range, the range a style alone. + h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "height", "height_range": [0, 1]})).unwrap(); + let ramp = gpu_clouds(&h.app, &h.ctx); + assert_eq!((ramp[0].style.mode, ramp[0].style.range), (ColorMode::Ramp, [0.0, 1.0])); + h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "height_range": [0, 2]})).unwrap(); + let wider = gpu_clouds(&h.app, &h.ctx); + assert_eq!((wider[0].version, wider[0].style.range), (ramp[0].version, [0.0, 2.0]), "a new range uploads nothing"); // Hidden, alone or with its folder: nothing drawn; the view frames it while shown. assert!(!solidcraft_engine::view::bounds(&h.app.session).is_empty()); let first_bounds = crate::viewport::model_bounds(&h.app); assert!(!first_bounds.is_empty()); assert_eq!(crate::viewport::model_bounds(&h.app), first_bounds, "cached sketch bounds keep the point cloud extent"); h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "visible": false})).unwrap(); - assert!(gpu_points(&h.app).is_empty()); + assert!(gpu_clouds(&h.app, &h.ctx).is_empty()); assert!(solidcraft_engine::view::bounds(&h.app.session).is_empty()); assert!(crate::viewport::model_bounds(&h.app).is_empty()); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn an_automatic_range_is_what_is_kept_and_follows_a_playing_filter() { + let mut h = crate::scenario::Harness::new(); + let dir = std::env::temp_dir().join(format!("solidcraft-ui-cloud-play-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.ply"); + // Eleven points, x and t both running 0 to 10. + let mut ply = String::from( + "ply\nformat ascii 1.0\nelement vertex 11\nproperty float x\nproperty float y\nproperty float z\nproperty float t\nend_header\n", + ); + for i in 0..=10 { + ply += &format!("{i} 0 0 {i}\n"); + } + std::fs::write(&path, ply).unwrap(); + let id = h.app.run("pointcloud.insert", serde_json::json!({"path": path.to_string_lossy()})).unwrap()["cloud"].as_u64().unwrap(); + // Filtered by x, not by t: the range is what is kept of t, 2 to 4, once worked out. + h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "t", "filters": [{"property": "x", "range": [2, 4]}]})).unwrap(); + let range = |h: &crate::scenario::Harness, playing: Option<[f64; 2]>| style(&h.ctx, &h.app.session.doc.point_clouds[0], |_| playing).range; + let settle = |h: &crate::scenario::Harness, playing: Option<[f64; 2]>, want: [f32; 2]| { + (0..500).any(|_| { + let ok = range(h, playing) == want; + if !ok { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + ok + }) + }; + assert!(settle(&h, None, [2.0, 4.0]), "{:?}", range(&h, None)); + // Played on to 8, the filter is drawn there, and the range follows it. + assert!(settle(&h, Some([2.0, 8.0]), [2.0, 8.0]), "{:?}", range(&h, Some([2.0, 8.0]))); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index e1eda41878..3eaa2e09df 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -281,6 +281,8 @@ pub const PAGE_SKETCH: usize = 5; pub const PAGE_GRAPHICS: usize = 6; pub const PAGE_AI: usize = 7; pub const PAGE_PLUGINS: usize = 8; +/// 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 { @@ -917,6 +919,10 @@ preference_settings! { /// 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; + /// 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"); /// Speed multiplier for 3D mouse navigation. space_mouse_speed: f32 = 1.0, PAGE_NAVIGATION; /// Per physical axis of the cap (push X, Y, Z, then tilt X, Y, Z), whether it moves the view @@ -1006,9 +1012,20 @@ preference_settings! { /// Decimals shown for lengths and angles. length_decimals: u8 = 2, PAGE_DESIGN; angle_decimals: u8 = 1, PAGE_DESIGN; + /// The most parameters Down arrow lists in an empty expression box (#9246): fewer keeps the + /// list light in a large design. + parameter_list_rows: u16 = 50, PAGE_DESIGN; /// Up/Down and the wheel over a focused value field step its plain number (`value_step`, /// #2320). Off by default: the keys move the text cursor and the wheel scrolls, as before. step_values: bool = false, PAGE_DESIGN => check_id("ui.step_values"); + /// A command's value (Extrude's Distance, Fillet's Radius…) in a box beside its handle in the + /// view (`canvas::boxes_shown`). Off: it is typed in the command window only, which takes the + /// keyboard after a pick instead, and nothing covers the model (#9479). + value_boxes_in_view: bool = true, PAGE_DESIGN => check_id("prefs.value_boxes_in_view"); + /// The Parameters dialog applies an expression as soon as its field is left (#9279). Off: the + /// edits wait in the dialog until Apply or OK sets them together, in one rebuild and one undo + /// step. Its checkbox is in the dialog itself. + parameters_apply_live: bool = true; /// A design coming back to its tab reads again the linked designs whose files changed since /// it last read them. Off: they wait for Update Links, as before. update_changed_links: bool = true, PAGE_DESIGN => check_id("settings.update_changed_links"); @@ -1060,6 +1077,11 @@ preference_settings! { navigation_bar: crate::navigation_bar::NavigationBar = Default::default(), PAGE_INTERFACE; /// Show command names beneath ribbon icons, independently of workspace layout. ribbon_labels: bool = true, PAGE_INTERFACE; + /// 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"); /// 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"); @@ -1075,6 +1097,12 @@ preference_settings! { toast_place: crate::viewport::ToastPlace = Default::default(), PAGE_INTERFACE; /// Toolbar tabs hidden by the person. An empty list keeps every existing tab visible. hidden_toolbar_tabs: Vec = Vec::new(), PAGE_INTERFACE; + /// 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; + /// 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"); /// Interface scale (1 = 100 %). ui_scale: f32 = 1.0, PAGE_INTERFACE, accept(0.5..=2.5); /// Interface font: an installed font family (or a font file), or empty for the built-in one @@ -1086,6 +1114,9 @@ preference_settings! { /// 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"); + /// 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"); /// Put the feature history to the left of the browser instead of below the viewport. timeline_left: bool = false, PAGE_INTERFACE; /// Measured outer width; the contents react to this width without a names mode. @@ -1138,8 +1169,11 @@ preference_settings! { origin_plane_names: OriginPlaneNames = OriginPlaneNames::default(), PAGE_INTERFACE; /// When components' bodies sit in a Bodies folder in the browser (#4217). browser_bodies_folders: BodiesFolders = BodiesFolders::Several, PAGE_INTERFACE; + /// 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"); /// 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 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. wheel_zoom_speed: f32 = 1.0, PAGE_NAVIGATION, accept(0.25..=4.0) => slider("ui.wheel_zoom_speed", 0.25..=4.0, 0.05, |v| format!("{v:.2}×"), "prefs:wheel_zoom_speed"); /// Opt-in surface anchoring for the 3D mouse, navigation bridge and drag-zoom. The wheel @@ -1207,6 +1241,9 @@ preference_settings! { /// Personal Hole parameter combinations, shared by every design in this profile. /// No page reset discards this user-authored library. hole_presets: crate::hole_presets::Library = crate::hole_presets::Library::default(); + /// Personal Sheet Metal Rules, offered by the rule manager of every design in this profile + /// (#9346). No page reset discards this user-authored library. + personal_sheet_rules: crate::personal_sheet_rules::Library = crate::personal_sheet_rules::Library::default(); /// Save as mesh: the last format (`stl` or `3mf`). Older versions also kept a "send to slicer" /// choice here (`save_mesh_send`, `save_mesh_slicer`); a settings file that still has them loads, /// and they are ignored. @@ -1219,6 +1256,9 @@ preference_settings! { send_slicer: String = String::new(); /// Slicers that can load a file into the window that is already open do so (default on). slicer_single_instance: bool = true, 0; + /// How strongly a print face stands out in the viewport (#8429): the share of the section + /// colour in its tint, 0 to 1; its outline follows. 0 draws no highlight. + print_face_highlight: f32 = 0.2, PAGE_GENERAL, accept(0.0..=1.0); /// The line about sending again replacing the slicer's file has been shown. slicer_resend_hint_seen: bool = false; /// The user has agreed to Open with EasyPrint uploading a model to Printables' servers. @@ -1293,9 +1333,14 @@ preference_settings! { /// A sketch's icon in History and the browser takes the colour of its solve state, and its /// tooltip names the state (#7245). Off: every sketch has the same icon, as before. sketch_state_icons: bool = true, PAGE_SKETCH => check_id("prefs.sketch_state_icons"); + /// Show the construction-line family in Sketch CREATE; off restores the earlier menu. + sketch_construction_lines: bool = true, PAGE_SKETCH => check_id("prefs.sketch_construction_lines"); /// 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; + /// 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; /// 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 @@ -1319,6 +1364,8 @@ 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"); + /// 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. /// When false (default): Shift and Primary both toggle. selection_shift_add_only: bool = false, PAGE_SKETCH; @@ -1328,6 +1375,10 @@ preference_settings! { /// holds, and its glyph or dimension, are drawn in `sketch_conflict` (#7244). Off: only the /// glyphs and dimensions are red, in `error`, as before. sketch_conflict_colour: bool = true, PAGE_SKETCH => check_id("prefs.sketch_conflict_colour"); + /// Hide constraint glyphs when the sketch is zoomed out so far that a millimetre of the plane + /// is smaller than `sketch_tools::MIN_GLYPH_PX_PER_MM` on screen (#8980). Off: glyphs stay at a + /// 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; /// Plugin ids turned off (Preferences › Plugins). An installed plugin whose id is absent is on. @@ -1720,7 +1771,7 @@ fn theme_row(app: &mut SolveApp, ui: &mut egui::Ui) { } } Some("export") => { - let system_dark = ui.ctx().system_theme().map_or(app.ui.dark, |theme| theme == egui::Theme::Dark); + let system_dark = system_dark(app, ui.ctx()).unwrap_or(app.ui.dark); let file = theme_file(app, action_theme.as_deref().unwrap_or(&app.preferences.theme), system_dark); let name = file.get("name").and_then(serde_json::Value::as_str).unwrap_or("Theme").to_string(); let text = serde_json::to_string_pretty(&file).unwrap_or_default(); @@ -1862,6 +1913,39 @@ fn defaults(app: &mut SolveApp, page: usize) { } } +/// Whether the desktop asks for dark colours, for Theme: System; `None` when nothing says. +/// The host's own reading of the desktop (`Services::desktop_dark`, the freedesktop setting on +/// Linux, #7944) comes first, as the window toolkit does not report it on every desktop +/// (GNOME on Wayland), nor its changes; then the window toolkit's. +pub(crate) fn system_dark(app: &SolveApp, ctx: &egui::Context) -> Option { + app.services.desktop_dark.as_ref().and_then(|f| f()).or_else(|| ctx.system_theme().map(|t| t == egui::Theme::Dark)) +} + +/// The desktop's dark or light preference from the freedesktop settings a Linux host reads +/// (#7944): `appearance` is `org.freedesktop.appearance color-scheme` (1 prefer dark, 2 prefer +/// light, 0 no preference), `gnome` is GNOME's `org.gnome.desktop.interface color-scheme` +/// (`prefer-dark`, `prefer-light`, `default`) and `gtk_theme` its `gtk-theme` name. An explicit +/// preference wins; otherwise a dark-named GTK theme ("Adwaita-dark") means dark, and anything +/// else light, as GTK applications read it (GNOME's Light is `default`). `None` when no setting +/// was read at all. +pub fn desktop_prefers_dark(appearance: Option, gnome: Option<&str>, gtk_theme: Option<&str>) -> Option { + match appearance { + Some(1) => return Some(true), + Some(2) => return Some(false), + _ => {} + } + match gnome.map(|s| s.trim().trim_matches('\'')) { + Some("prefer-dark") => return Some(true), + Some("prefer-light") => return Some(false), + _ => {} + } + let theme = gtk_theme.map(|s| s.trim().trim_matches('\'').to_ascii_lowercase()).filter(|s| !s.is_empty()); + if theme.as_deref().is_some_and(|t| t.ends_with("-dark") || t.ends_with(":dark")) { + return Some(true); + } + (appearance.is_some() || gnome.is_some() || theme.is_some()).then_some(false) +} + /// Settings that follow the preferences every frame: the theme when it follows the system, and /// the interface scale. /// Tell the native window which appearance to take (#8983): the operating system's while the @@ -1898,9 +1982,9 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { } follow_view_fov(app); if app.preferences.theme == "system" - && let Some(t) = ctx.system_theme() + && let Some(dark) = system_dark(app, ctx) { - app.ui.dark = t == egui::Theme::Dark; + app.ui.dark = dark; } // 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. @@ -2443,6 +2527,9 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { }); ui.allocate_ui_with_layout(egui::vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { setting::space_mouse_in_view(app, ui).on_hover_text(crate::i18n::tr("ui.3d_mouse_show_in_view_tip")); + ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| { + setting::space_mouse_sketch_orbit(app, ui).on_hover_text(crate::i18n::tr("ui.3d_mouse_sketch_orbit_tip")); + }); }); }); // Where the picture goes, a choice that waits on the checkbox above. @@ -2715,7 +2802,7 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { }); // The calibrated device moving as it does, with the test model beside it, then its dead zones. let (device, raw) = (app.space_mouse.device.clone(), app.space_mouse.raw.into()); - let (boat, motion, reverse) = (&mut app.space_mouse.boat, app.space_mouse.motion, (app.preferences.zoom_reverse, app.preferences.free_orbit)); + let (boat, motion, free_orbit) = (&mut app.space_mouse.boat, app.space_mouse.motion, app.preferences.free_orbit); if let Some(calibration) = device.as_ref().and_then(|device| app.preferences.space_mouse_calibrations.get_mut(device)) { ui_kit::divider(ui, "space_mouse_picture"); // Under each circle's axes, what the view does with them: the rows' own labels. @@ -2725,11 +2812,11 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { crate::space_mouse_calibration::dead_zone_pads(ui, calibration, raw, false, &stories); ui_kit::text(ui, &help, ui_kit::Text::Hint("space_mouse_circles_help")); }; - picture_and_boat(ui, boat, motion, reverse, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads); + picture_and_boat(ui, boat, motion, free_orbit, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads); } else if device.is_some() { // Not calibrated: the test model alone. ui_kit::divider(ui, "space_mouse_picture"); - picture_and_boat(ui, boat, motion, reverse, false, |_| {}, |_| {}); + picture_and_boat(ui, boat, motion, free_orbit, false, |_| {}, |_| {}); } ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { hint_under_controls(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only."), "space_mouse"); @@ -2794,6 +2881,7 @@ pub fn restore_space_mouse_defaults(app: &mut SolveApp) { p.space_mouse_ghost_position = d.space_mouse_ghost_position; p.space_mouse_in_view = d.space_mouse_in_view; p.space_mouse_model_pivot = d.space_mouse_model_pivot; + p.space_mouse_sketch_orbit = d.space_mouse_sketch_orbit; } /// The device's picture with the coordinate circles under it, and a box with a small model that @@ -2803,7 +2891,7 @@ fn picture_and_boat( ui: &mut egui::Ui, boat: &mut Option, motion: Option, - (reverse_zoom, free_orbit): (bool, bool), + free_orbit: bool, has_picture: bool, picture: impl FnOnce(&mut egui::Ui), pads: impl FnOnce(&mut egui::Ui), @@ -2818,7 +2906,7 @@ fn picture_and_boat( let width = if beside { (room - PICTURE_WIDTH - gap).min(BOAT_MAX) } else { room.min(BOAT_MAX) }; let mut boat_and_reset = |ui: &mut egui::Ui| { ui.vertical(|ui| { - crate::test_boat::show(ui, boat, motion, reverse_zoom, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT)); + crate::test_boat::show(ui, boat, motion, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT)); if ui_kit::button(ui, crate::i18n::tr("ui.3d_mouse_reset_view"), ui_kit::Button::Normal).clicked() { crate::test_boat::reset(boat); } @@ -3414,6 +3502,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result Result serde_json::Value { + serde_json::json!({ + "name": name, "thickness": "1.6 mm", "k_factor": "0.42", "bend_radius": "Thickness", + "relief_width": "Thickness", "relief_depth": "Thickness * 0.5", "corner_relief": "Thickness * 4.0", + "corner_relief_shape": "round", "hem_gap": "0.01 mm", "gap": "Thickness", "angle_display": "included" + }) + } + + #[test] + fn personal_sheet_rules_corrupt_rows_and_limits_do_not_discard_other_preferences() { + use serde_json::json; + let good = personal_sheet_rule_row("CRS 1.6"); + let mut oversized = personal_sheet_rule_row("Too long"); + oversized["thickness"] = json!("x".repeat(513)); + let mut duplicate = good.clone(); + duplicate["name"] = json!("crs 1.6"); + let p = super::lenient(&json!({"length_decimals":4,"personal_sheet_rules":[null,good,oversized,duplicate,{"name":"incomplete"}]})); + assert_eq!(p.length_decimals, 4); + assert_eq!(serde_json::to_value(&p.personal_sheet_rules).unwrap(), json!([personal_sheet_rule_row("CRS 1.6")])); + let corrupt = super::lenient(&json!({"length_decimals":3,"personal_sheet_rules":{"unexpected":true}})); + assert_eq!(corrupt.length_decimals, 3); + assert_eq!(serde_json::to_value(corrupt.personal_sheet_rules).unwrap(), json!([])); + let many: Vec<_> = (0..100).map(|i| personal_sheet_rule_row(&format!("Rule {i}"))).collect(); + let capped = super::lenient(&json!({"personal_sheet_rules":many})); + assert_eq!(serde_json::to_value(capped.personal_sheet_rules).unwrap().as_array().unwrap().len(), 64); + } + + #[test] + fn personal_sheet_rules_survive_application_preferences_roundtrip_and_page_resets() { + use serde_json::json; + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + super::set(&mut app, &json!({"personal_sheet_rules":[personal_sheet_rule_row("CRS 1.6")]})).unwrap(); + let expected = serde_json::to_value(&app.preferences.personal_sheet_rules).unwrap(); + assert_eq!(expected[0]["angle_display"], "included"); + for page in 0..super::PAGES.len() { + super::reset_preferences(&mut app.preferences, page); + } + assert_eq!(serde_json::to_value(&app.preferences.personal_sheet_rules).unwrap(), expected); + let saved = app.prefs(); + let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + other.load_prefs(&saved); + assert_eq!(serde_json::to_value(&other.preferences.personal_sheet_rules).unwrap(), expected); + assert!(other.session.doc.sheet.rules.is_empty(), "a personal rule is not a rule of the design"); + } + /// The interface font (#1290): a font that can be read, or empty for the built-in one; saved /// with the others and reset by Restore Defaults. #[test] @@ -5154,6 +5304,59 @@ mod tests { assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Several, "Interface's Restore Defaults"); } + /// Theme: System on a Linux desktop (#7944): the freedesktop preference wins, GNOME's + /// settings decide when it has none (its Light is `default`, so a plain theme name is + /// light), and nothing read leaves the window toolkit to say. + #[test] + fn the_desktops_colour_scheme_setting_decides_dark_or_light() { + use super::desktop_prefers_dark as d; + assert_eq!(d(Some(1), Some("prefer-light"), Some("Adwaita")), Some(true), "freedesktop: prefer dark"); + assert_eq!(d(Some(2), Some("prefer-dark"), Some("Adwaita-dark")), Some(false), "freedesktop: prefer light"); + assert_eq!(d(Some(0), Some("prefer-dark"), None), Some(true), "no preference: GNOME's"); + assert_eq!(d(Some(0), Some("'prefer-light'"), Some("Adwaita-dark")), Some(false), "gsettings quotes its answer"); + assert_eq!(d(Some(0), Some("default"), Some("Adwaita-dark")), Some(true), "a dark GTK theme"); + assert_eq!(d(None, None, Some("Yaru:dark")), Some(true)); + assert_eq!(d(Some(0), Some("default"), Some("Adwaita")), Some(false), "GNOME's Light"); + assert_eq!(d(Some(0), None, None), Some(false), "no preference at all: light, as GTK applications"); + assert_eq!(d(Some(u32::MAX), None, None), Some(false), "an unknown value is no preference"); + assert_eq!(d(None, None, Some(" ")), None, "nothing read"); + assert_eq!(d(None, None, None), None); + } + + /// The host's reading of the desktop comes before the window toolkit's theme, which egui + /// reports as None in tests, and Theme: System follows it every frame (#7944). + #[test] + fn theme_system_follows_the_hosts_desktop_setting() { + use std::sync::Arc; + use std::sync::atomic::{AtomicU8, Ordering}; + let desktop = Arc::new(AtomicU8::new(0)); + let reading = desktop.clone(); + let services = crate::Services { + desktop_dark: Some(Box::new(move || match reading.load(Ordering::Relaxed) { + 1 => Some(true), + 2 => Some(false), + _ => None, + })), + ..Default::default() + }; + let mut app = SolveApp::new(solidcraft_engine::Session::default(), services); + app.preferences.theme = "system".into(); + app.ui.dark = true; + let ctx = egui::Context::default(); + super::apply(&mut app, &ctx); + assert!(app.ui.dark, "nothing known: the colours stay"); + desktop.store(2, Ordering::Relaxed); + super::apply(&mut app, &ctx); + assert!(!app.ui.dark, "the desktop turned light"); + desktop.store(1, Ordering::Relaxed); + super::apply(&mut app, &ctx); + assert!(app.ui.dark, "and dark again"); + app.preferences.theme = "light".into(); + app.ui.dark = false; + super::apply(&mut app, &ctx); + assert!(!app.ui.dark, "an explicit theme does not follow the desktop"); + } + /// Theme files (#2255): a built-in one and an imported one turn on through `ui.prefs`, the /// next frame draws with their colours (dark following the base), an export gives the theme /// back complete, imported themes are saved with the preferences, Remove falls back to the @@ -6112,6 +6315,42 @@ mod tests { } } + #[test] + fn history_preferences_widget_click_reveals_scrolled_content_and_keeps_folds_closed() { + let steps: serde_json::Value = serde_json::from_str(include_str!("../tests/scenarios/history_sketches_under_feature.json")).unwrap(); + let steps = steps.as_array().unwrap(); + let transition = steps.iter().position(|step| step["params"]["history_sketches"] == "under_feature").unwrap(); + let mut h = crate::scenario::Harness::new(); + for step in steps.iter().take(transition) { + h.step(step).unwrap(); + } + h.call("ui.prefs", json!({"open":"Interface"})); + 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_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); + let at = h.widget("Under their feature").unwrap(); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains(egui::pos2(at[0] as f32, at[1] as f32))); + assert!(h.app.tree.expanded.is_empty(), "revealing a preference does not open history folds"); + let page_before = crate::scenario::rect_of("prefs:page_body"); + h.step(&json!({"widget":"Apply"})).unwrap(); + assert!(h.app.prefs_window.open); + assert_eq!(crate::scenario::rect_of("prefs:page_body"), page_before); + h.step(&json!({"widget":"OK"})).unwrap(); + h.step(&steps[transition + 1]).unwrap(); + assert!(!h.app.prefs_window.open); + for step in steps.iter().skip(transition + 2) { + h.step(step).unwrap(); + } + h.call("ui.prefs", json!({"open":"Interface"})); + 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(); + assert!(!h.app.prefs_window.open); + } + #[test] fn history_sketches_defaults_in_order_and_follows_preferences_transactions() { let old: Prefs = serde_json::from_value(json!({"timeline_left": true})).unwrap(); @@ -6260,6 +6499,178 @@ mod tests { assert_eq!(app.session.undo.len(), undo); } + #[test] + fn persistent_ribbon_pressed_preference_checkbox_control_persists_cancels_and_resets() { + assert!(Prefs::default().persistent_ribbon_pressed); + assert!(lenient(&json!({"theme":"light"})).persistent_ribbon_pressed); + assert!(lenient(&json!({"persistent_ribbon_pressed":"no"})).persistent_ribbon_pressed); + 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"})); + 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})); + assert!(!h.app.preferences.persistent_ribbon_pressed); + 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); + 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!({"button":"defaults"})); + assert!(h.app.preferences.persistent_ribbon_pressed); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(!h.app.preferences.persistent_ribbon_pressed); + for malformed in [json!("false"), json!(null), json!(0), json!([])] { + assert!(super::set(&mut h.app, &json!({"persistent_ribbon_pressed":malformed})).is_err()); + assert!(!h.app.preferences.persistent_ribbon_pressed); + } + assert_eq!(h.app.session.revision, revision); + assert_eq!(h.app.session.doc.to_json(), design); + } + + #[test] + fn viewport_analysis_status_preference_checkbox_control_persists_cancels_and_resets() { + assert!(Prefs::default().viewport_analysis_status); + assert!(lenient(&json!({"theme":"light"})).viewport_analysis_status); + assert!(lenient(&json!({"viewport_analysis_status":"no"})).viewport_analysis_status); + 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"})); + 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})); + assert!(!h.app.preferences.viewport_analysis_status); + 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); + 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!({"button":"defaults"})); + assert!(h.app.preferences.viewport_analysis_status); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(!h.app.preferences.viewport_analysis_status); + for malformed in [json!("false"), json!(null), json!(0), json!([])] { + assert!(super::set(&mut h.app, &json!({"viewport_analysis_status":malformed})).is_err()); + assert!(!h.app.preferences.viewport_analysis_status); + } + assert_eq!(h.app.session.revision, revision); + assert_eq!(h.app.session.doc.to_json(), design); + } + + #[test] + fn viewport_analysis_status_is_independent_of_session_policy_in_background_documents() { + let mut app = SolveApp::new(Default::default(), Default::default()); + super::set(&mut app, &json!({"single_surface_analysis":false,"persistent_ribbon_pressed":false})).unwrap(); + app.run("inspect.zebra", json!({})).unwrap(); + let mut other = solidcraft_engine::Session::default(); + other.execute("inspect.draft", &json!({})).unwrap(); + 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(); + 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())); + super::control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert!(app.preferences.viewport_analysis_status); + let after: Vec<_> = + std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect(); + assert_eq!(before, after, "a presentation preference never changes effects in any document"); + } + + #[test] + fn viewport_section_status_preference_checkbox_control_persists_cancels_and_resets() { + assert!(Prefs::default().viewport_section_status); + assert!(lenient(&json!({"theme":"light"})).viewport_section_status); + assert!(lenient(&json!({"viewport_section_status":"no"})).viewport_section_status); + 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"})); + 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})); + assert!(!h.app.preferences.viewport_section_status); + 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); + 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!({"button":"defaults"})); + assert!(h.app.preferences.viewport_section_status); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(!h.app.preferences.viewport_section_status); + for malformed in [json!("false"), json!(null), json!(0), json!([])] { + assert!(super::set(&mut h.app, &json!({"viewport_section_status":malformed})).is_err()); + assert!(!h.app.preferences.viewport_section_status); + } + 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); + assert!(lenient(&json!({"theme":"light"})).viewport_status_summary); + assert!(lenient(&json!({"viewport_status_summary":"no"})).viewport_status_summary); + 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"})); + 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})); + assert!(!h.app.preferences.viewport_status_summary); + 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); + 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!({"button":"defaults"})); + assert!(h.app.preferences.viewport_status_summary); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(!h.app.preferences.viewport_status_summary); + for malformed in [json!("false"), json!(null), json!(0), json!([])] { + assert!(super::set(&mut h.app, &json!({"viewport_status_summary":malformed})).is_err()); + assert!(!h.app.preferences.viewport_status_summary); + } + assert_eq!(h.app.session.revision, revision); + assert_eq!(h.app.session.doc.to_json(), design); + } + + #[test] + fn viewport_section_status_is_independent_of_session_policy_in_background_documents() { + let mut app = SolveApp::new(Default::default(), Default::default()); + super::set(&mut app, &json!({"single_surface_analysis":false,"persistent_ribbon_pressed":false})).unwrap(); + app.run("inspect.zebra", json!({})).unwrap(); + app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + let mut other = solidcraft_engine::Session::default(); + other.execute("inspect.draft", &json!({})).unwrap(); + other.execute("inspect.section", &json!({"plane":"XZ","at":-3})).unwrap(); + 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(); + 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())); + super::control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert!(app.preferences.viewport_section_status); + let after: Vec<_> = + std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect(); + assert_eq!(before, after, "a presentation preference never changes effects in any document"); + } + #[test] fn single_surface_analysis_preference_checkbox_persists_cancels_and_resets() { assert!(Prefs::default().single_surface_analysis); diff --git a/crates/ui-egui/src/prefs/design.rs b/crates/ui-egui/src/prefs/design.rs index f33bb8ec39..a98cf2ea28 100644 --- a/crates/ui-egui/src/prefs/design.rs +++ b/crates/ui-egui/src/prefs/design.rs @@ -31,10 +31,16 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ui.add_space(t.space_6); ui_kit::settings_precision_sample(ui, &format!("{:.*}°", usize::from(app.preferences.angle_decimals.min(6)), 45.0)); }); + ui_kit::row(ui, app.tr("prefs.parameter_list_rows"), |ui| { + let tip = app.tr("prefs.parameter_list_rows_tip").to_string(); + ui_kit::settings_number(ui, &mut app.preferences.parameter_list_rows, PARAMETER_LIST_ROWS, 1.0).on_hover_text(tip); + }); }); let tip = app.tr("ui.step_values_tip").to_string(); let step = setting::step_values(app, ui).on_hover_text(tip); crate::scenario::publish_handle("prefs:step_values", step.rect.center()); + let tip = app.tr("prefs.value_boxes_in_view_tip").to_string(); + setting::value_boxes_in_view(app, ui).on_hover_text(tip); }); ui_kit::settings_section(ui, "orientation", app.tr("prefs.orientation"), |ui| { ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/src/prefs/general.rs b/crates/ui-egui/src/prefs/general.rs index d0f2f7e789..1bd2564cee 100644 --- a/crates/ui-egui/src/prefs/general.rs +++ b/crates/ui-egui/src/prefs/general.rs @@ -56,6 +56,17 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string()); ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).on_hover_text(tip); }); + ui_kit::settings_block(ui, app.tr("prefs.print_face_highlight"), labels, |ui| { + let tip = app.tr("prefs.print_face_highlight_tip").to_string(); + let response = ui_kit::slider( + ui, + &mut app.preferences.print_face_highlight, + 0.0..=1.0, + ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, + ); + crate::scenario::publish_target("prefs:print_face_highlight", response.rect); + response.on_hover_text(tip); + }); }); ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| { ui_kit::settings_block(ui, app.tr("update.prefs_label"), labels, |ui| crate::update::prefs_rows(app, ui)); diff --git a/crates/ui-egui/src/prefs/interface.rs b/crates/ui-egui/src/prefs/interface.rs index 6a98c12f39..60f4add0cb 100644 --- a/crates/ui-egui/src/prefs/interface.rs +++ b/crates/ui-egui/src/prefs/interface.rs @@ -94,6 +94,22 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { app.preferences.ribbon_labels = selected == 0; } }); + ui_kit::row(ui, "", |ui| { + 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")); @@ -137,6 +153,14 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { crate::scenario::publish_handle(&format!("prefs:toast_place:{key}"), at); } } + // The ribbon's panel drop-downs: a list, or a grid of large icons (#4739). + ui_kit::row(ui, app.tr("prefs.menus"), |ui| { + let mut selected = usize::from(app.preferences.ribbon_icon_grid); + if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.menu_list"), app.tr("prefs.menu_grid")], Default::default()) { + app.preferences.ribbon_icon_grid = selected == 1; + } + }); + setting::ribbon_menus_skip_pinned(app, ui); ui_kit::row(ui, app.tr("prefs.history"), |ui| { let mut selected = if app.preferences.history_in_browser { 3 @@ -253,11 +277,21 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { app.preferences.browser_bodies_folders = *mode; } }); + // The eye beside the fold arrow, where it was before #8718. + ui_kit::row(ui, "", |ui| { + let check = setting::browser_eye_left(app, ui).on_hover_text(app.tr("prefs.browser_eye_left_note")); + crate::scenario::publish_target("prefs:browser_eye_left", check.rect); + }); // What is being edited, the sketch's state and the units, along the bottom (#7243). ui_kit::row(ui, "", |ui| { 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 { diff --git a/crates/ui-egui/src/prefs/navigation.rs b/crates/ui-egui/src/prefs/navigation.rs index 647395f289..2cd0d504a3 100644 --- a/crates/ui-egui/src/prefs/navigation.rs +++ b/crates/ui-egui/src/prefs/navigation.rs @@ -97,7 +97,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = t.space_8; for (index, (mode, label)) in - [(OrbitAround::Origin, "Origin"), (OrbitAround::ViewCentre, "View centre"), (OrbitAround::Cursor, "prefs.under_cursor")] + [(OrbitAround::Origin, "Origin"), (OrbitAround::ViewCentre, "View center"), (OrbitAround::Cursor, "prefs.under_cursor")] .into_iter() .enumerate() { diff --git a/crates/ui-egui/src/prefs/sketch.rs b/crates/ui-egui/src/prefs/sketch.rs index ba358cb269..2cd6c76ddb 100644 --- a/crates/ui-egui/src/prefs/sketch.rs +++ b/crates/ui-egui/src/prefs/sketch.rs @@ -24,6 +24,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // 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); + let construction = setting::sketch_construction_lines(app, ui); + crate::scenario::publish_target("prefs:sketch_construction_lines", construction.rect); let dimension = setting::sketch_auto_dimensions(app, ui); crate::scenario::publish_handle("prefs:sketch_auto_dimensions", dimension.rect.center()); ui.add_enabled_ui(app.preferences.sketch_auto_dimensions, |ui| { @@ -48,6 +50,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }); }); }); + ui_kit::checkbox(ui, app.tr("prefs.sketch_open_look_at"), &mut app.preferences.sketch_open_look_at) + .on_hover_text(app.tr("prefs.sketch_open_look_at_note")); ui_kit::settings_block_hint( ui, app.tr("prefs.sketch_exit_view"), @@ -72,10 +76,15 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Conflicting curves and points in the conflict colour (#7244). let check = setting::sketch_conflict_colour(app, ui).on_hover_text(app.tr("prefs.sketch_conflict_colour_note")); crate::scenario::publish_target("prefs:sketch_conflict_colour", check.rect); + // Constraint glyphs hide when zoomed out (#8980). + let check = setting::hide_constraint_glyphs_zoomed_out(app, ui).on_hover_text(app.tr("prefs.hide_constraint_glyphs_zoomed_out_note")); + crate::scenario::publish_target("prefs:hide_constraint_glyphs_zoomed_out", check.rect); }); ui_kit::settings_section(ui, "selection", app.tr("prefs.selecting"), |ui| { ui_kit::checkbox(ui, crate::i18n::tr_label("Click selects whole bodies"), &mut app.ui.pick_bodies); ui_kit::checkbox(ui, app.tr("prefs.prefer_free_sketch_points"), &mut app.preferences.prefer_free_sketch_points); + let hidden = setting::select_other_hidden(app, ui).on_hover_text(app.tr("prefs.select_other_hidden_help")); + crate::scenario::publish_target("prefs:select_other_hidden", hidden.rect); let click = app.tr("prefs.click"); let shift = app.tr("prefs.shift"); let ctrl = app.tr("prefs.ctrl_cmd"); diff --git a/crates/ui-egui/src/prefs_search.rs b/crates/ui-egui/src/prefs_search.rs index eee7bf6ed8..ad9514bf44 100644 --- a/crates/ui-egui/src/prefs_search.rs +++ b/crates/ui-egui/src/prefs_search.rs @@ -65,6 +65,10 @@ struct State { /// A heading matched, so the controls under it stay visible until the next heading. heading_open: bool, hiding: u32, + /// Set while a row with no label of its own runs hidden: what its controls say stands in for it. + unlabelled: u32, + /// Rows drawn this page with no label, so each gets a key of its own. + unlabelled_rows: u32, key: String, hits: u32, page_index: usize, @@ -259,6 +263,7 @@ pub(crate) fn begin_page(ui: &Ui, index: usize, label: &str) { state.page_index = index; state.page = label.to_string(); state.page_shown = false; + state.unlabelled_rows = 0; state.section.clear(); state.key.clear(); state.section_matches = false; @@ -271,6 +276,7 @@ pub(crate) fn begin_section(ui: &Ui, title: &str) { let matches = text_hit(ui, title); with(ui, |state| { state.section = title.to_string(); + state.unlabelled_rows = 0; state.section_matches = matches || state.force > 0; state.section_shown.clear(); // A dropdown with no row of its own must not store its value under the last row's key. @@ -335,6 +341,53 @@ pub(crate) fn set_control(ui: &Ui, label: &str) { with(ui, |state| state.key = format!("{}\n{}\n{}", state.page, state.section, label)); } +/// Key a row that has no label of its own by its place in the section, so two of them do not share +/// one stored text. Returns the text to match the row on (still empty). +pub(crate) fn set_unlabelled_control(ui: &Ui) { + with(ui, |state| { + state.unlabelled_rows += 1; + state.key = format!("{}\n{}\n#{}", state.page, state.section, state.unlabelled_rows); + }); +} + +pub(crate) fn push_unlabelled(ui: &Ui) { + with(ui, |state| state.unlabelled = state.unlabelled.saturating_add(1)); +} + +pub(crate) fn pop_unlabelled(ui: &Ui) { + with(ui, |state| state.unlabelled = state.unlabelled.saturating_sub(1)); +} + +/// A control inside a row with no label says what it is. Stored for the row, so the next pass can +/// match the row on those words and draw it, as it does for a dropdown's current value. +pub(crate) fn note_unlabelled(ui: &Ui, texts: &[&str]) { + if !querying(ui) { + return; + } + with(ui, |state| { + if state.hiding == 0 || state.unlabelled == 0 || state.key.is_empty() { + return; + } + let mut stored = state.extras.get(&state.key).cloned().unwrap_or_default(); + let was = extra_hit_text(state, Some(stored.as_str())); + for text in texts.iter().filter(|text| !text.is_empty()) { + if !stored.contains(text) { + if !stored.is_empty() { + stored.push(' '); + } + stored.push_str(text); + } + } + let now = extra_hit_text(state, Some(stored.as_str())); + let key = state.key.clone(); + state.extras.insert(key, stored); + if was != now { + state.child_matched = true; + state.dirty = true; + } + }); +} + /// Draw this control. A miss returns false and draws nothing; the caller still runs the control /// off-screen when it needs the widget's current value. pub(crate) fn visible(ui: &mut Ui, texts: &[&str]) -> bool { @@ -847,4 +900,43 @@ mod tests { assert!(h.widget("Reverse zoom direction").is_none()); assert!(h.widget("Units of new designs").is_some()); } + /// Settings drawn in a row with no label of their own are found by the words they show (#9265). + #[test] + 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); + 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| { + let body = crate::scenario::rect_of("dialog:page_body"); + h.widget(label).is_some_and(|[x, y]| body.is_none_or(|body| body.contains(egui::pos2(x as f32, y as f32)))) + }; + let rows = [ + ("status bar", "Status bar"), + ("sketch tab shown", "Keep the Sketch tab shown"), + ("search results", "Show search results in the ribbon"), + ("play history", "Play History button in the History controls"), + ("view's corner", "OK and Cancel in the view's corner"), + ("highlight active", "Highlight active ribbon commands"), + ("reveal on hover", "Reveal on hover"), + ]; + for (query, label) in rows { + h.app.prefs_window.filter = query.into(); + h.frames(4); + assert!(drawn(&h, label), "searching '{query}' should show '{label}'"); + } + h.app.prefs_window.filter = "status bar".into(); + h.frames(4); + assert!(!drawn(&h, "Keep the Sketch tab shown"), "a row that does not match stays out of the result"); + // Three unlabelled rows sit under one labelled row: each is found on its own words only. + h.app.prefs_window.filter = "sketch tab shown".into(); + h.frames(4); + assert!(drawn(&h, "Keep the Sketch tab shown")); + assert!(!drawn(&h, "Highlight active ribbon commands"), "its neighbour is not a match"); + assert!(!drawn(&h, "Show search results in the ribbon"), "its neighbour is not a match"); + h.app.prefs_window.filter = "zzzz-no-setting".into(); + h.frames(4); + assert!(!drawn(&h, "Status bar"), "a miss shows nothing"); + } } diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 87cbe13570..77b4bfb06e 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -2773,7 +2773,9 @@ fn hovering_a_large_body_does_not_wait_for_its_pick_index() { 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); - assert!(hover_ms < index_ms / 3.0, "the first hover took {hover_ms:.1} ms; making the index takes {index_ms:.1} ms"); + // 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"); } /// #4452: with Combine open, bodies clicked in the Browser fill its inputs, as clicks on them @@ -2957,3 +2959,30 @@ fn construction_points_fill_three_point_plane_dialog() { assert!((planes[0].2.origin.z - 10.0).abs() < 1e-9); assert!(planes[0].2.normal().dot(Vec3::Z).abs() > 1.0 - 1e-9); } + +/// #6106: with Extrude open, a sketch clicked in the Browser fills Profiles with all of its +/// profiles, so one OK extrudes every area. +#[test] +fn extrude_takes_every_profile_of_a_sketch_clicked_in_the_browser() { + 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.rectangle.two_point", json!({"p0": [40, 0], "p1": [50, 10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center": [0, 40], "radius": 5})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.session.selection.clear(); + h.app.start("solid.extrude"); + h.frames(2); + h.step(&json!({"click": {"browser": "Sketch1"}})).unwrap(); + h.frames(2); + let d = h.app.dialog.as_ref().expect("Extrude stays open"); + let first = + h.app.session.doc.features.iter().find(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::Sketch { .. })).map(|f| f.id).unwrap(); + assert_eq!( + d.inputs[0].items, + vec![Sel::Profile { sketch: first, index: 0 }, Sel::Profile { sketch: first, index: 1 }], + "both rectangles of the clicked sketch, not the last sketch's circle" + ); +} diff --git a/crates/ui-egui/src/print_face_ui.rs b/crates/ui-egui/src/print_face_ui.rs new file mode 100644 index 0000000000..6a8125e21c --- /dev/null +++ b/crates/ui-egui/src/print_face_ui.rs @@ -0,0 +1,104 @@ +//! The print face in the app (#8429): Ctrl+B and the face menu mark the selected flat face as its +//! body's print face, or clear it when it already is one; the Send to Slicer window shows and +//! changes it (`dialogs_save_mesh`). Each change is a `body.print_face` command, so it undoes. + +use serde_json::json; +use solidcraft_engine::Sel; + +use crate::SolveApp; + +/// Whether face `index` of `body` (as the viewport names it) is that body's print face now. +pub(crate) fn is_print_face(app: &SolveApp, body: &str, index: usize) -> bool { + let world = app.session.world_state(); + world.body(body).and_then(|b| app.session.doc.print_face(&world, b)).and_then(Result::ok).is_some_and(|f| f.index == index) +} + +/// `body`'s print face as a face pick, for the Send to Slicer window; `None` when it has none, +/// or it can't be found. +pub(crate) fn current(app: &SolveApp, body: &str) -> Option { + let world = app.session.world_state(); + let b = world.body(body)?; + let face = app.session.doc.print_face(&world, b)?.ok()?; + let point = solidcraft_engine::doc::naming::face_spot(b, face.index).map_or(face.point, |spot| spot.point); + Some(Sel::Face { body: body.to_string(), index: face.index, point }) +} + +/// Mark face `face` as its body's print face (a face pick), or clear `body`'s with none. +pub(crate) fn set(app: &mut SolveApp, body: &str, face: Option<&Sel>) -> bool { + let params = match face { + Some(Sel::Face { body, point, .. }) => json!({"body": body, "point": [point.x, point.y, point.z]}), + _ => json!({"body": body}), + }; + app.run("body.print_face", params).is_ok() +} + +/// Ctrl+B and the face menu: the one selected face becomes its body's print face, or stops being +/// it when it already is. +pub(crate) fn toggle(app: &mut SolveApp) { + let picked = match app.session.selection.as_slice() { + [face @ Sel::Face { body, index, .. }] => Some((face.clone(), body.clone(), *index)), + _ => None, + }; + let Some((face, body, index)) = picked else { + app.set_status(crate::i18n::tr("ui.print_face_pick"), true); + return; + }; + let clear = is_print_face(app, &body, index); + if set(app, &body, if clear { None } else { Some(&face) }) { + let id = if clear { "ui.print_face_cleared" } else { "ui.print_face_set" }; + app.set_status(crate::i18n::format(id, &[("body", &body)]), false); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use solidcraft_engine::Sel; + + fn app() -> crate::SolveApp { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.run("solid.box", json!({"length": 20, "width": 10, "height": 5, "corner": [0, 0, 0], "body_name": "Block"})).unwrap(); + app + } + + /// Select the face of Block at `point`; its index. + fn select_face(app: &mut crate::SolveApp, point: [f64; 3]) -> usize { + let world = app.session.world_state(); + let body = world.body("Block").unwrap(); + let p = solidcraft_engine::geom::Vec3::new(point[0], point[1], point[2]); + let index = solidcraft_engine::doc::appearance::face_index_at(body, p).unwrap(); + app.session.selection = vec![Sel::Face { body: "Block".into(), index, point: p }]; + index + } + + #[test] + fn ctrl_b_marks_the_selected_face_and_clears_it_the_second_time() { + let mut app = app(); + let index = select_face(&mut app, [20.0, 5.0, 2.5]); + app.start("body.print_face"); + assert!(app.session.doc.print_faces.contains_key("Block")); + assert!(super::is_print_face(&app, "Block", index)); + assert!(super::current(&app, "Block").is_some()); + app.start("body.print_face"); + assert!(app.session.doc.print_faces.is_empty()); + assert!(super::current(&app, "Block").is_none()); + } + + #[test] + fn ctrl_b_without_one_face_selected_marks_nothing() { + let mut app = app(); + app.session.selection = vec![Sel::Body { name: "Block".into() }]; + app.start("body.print_face"); + assert!(app.session.doc.print_faces.is_empty()); + } + + #[test] + fn the_highlight_strength_defaults_to_a_fifth_and_ui_prefs_keeps_it_in_range() { + let mut app = app(); + assert!((app.preferences.print_face_highlight - 0.2).abs() < 1e-6); + crate::prefs::set(&mut app, &json!({"print_face_highlight": 0.5})).unwrap(); + assert!((app.preferences.print_face_highlight - 0.5).abs() < 1e-6); + assert!(crate::prefs::set(&mut app, &json!({"print_face_highlight": 1.5})).is_err()); + assert!((app.preferences.print_face_highlight - 0.5).abs() < 1e-6); + } +} diff --git a/crates/ui-egui/src/quick_measure.rs b/crates/ui-egui/src/quick_measure.rs new file mode 100644 index 0000000000..d5b490b335 --- /dev/null +++ b/crates/ui-egui/src/quick_measure.rs @@ -0,0 +1,249 @@ +//! Quick measurement (#7000): what the selection measures, in the view's bottom right corner above +//! History, so a simple size needs no Measure tool. One item gives the Measure tool's rows (an +//! edge's length, Ø and R for a round one, a face's area, a point's position); two give their +//! distance and angle; several edges or several faces their count and total length or area. +//! Computed off the window thread from a copy of the session ([`Session::measure_items`]) whenever +//! the selection or the design changes; nothing shows while it computes, or for a selection with +//! nothing to measure. A click copies the readout. + +use serde_json::{Value, json}; +use solidcraft_engine::Sel; + +use crate::SolveApp; + +/// Items summed into a total, at most. +const MAX_SUMMED: usize = 200; + +/// What a selection and design were measured as, and the measurement once it is in. +#[derive(Default)] +pub struct QuickMeasure { + /// The selection and design the measurement is for. + key: Option, + job: Option>>, + result: Option, +} + +#[derive(Clone, PartialEq)] +struct Key { + selection: Vec, + doc: usize, + revision: u64, +} + +impl QuickMeasure { + /// The readout as shown: each line a label and its value. + pub(crate) fn lines(&self, app: &SolveApp) -> Vec<(String, String)> { + self.result.as_ref().map(|r| lines(app, r)).unwrap_or_default() + } +} + +/// Is the readout wanted at all: the preference, a design shown, no Measure tool saying the same. +fn wanted(app: &SolveApp) -> bool { + app.preferences.quick_measure + && !app.home.open + && !crate::render_workspace::active(app) + && !app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Measure { .. })) +} + +/// Can `sel` be measured here? A sketch's curves, points and regions are, open or only shown. +fn measurable(sel: &Sel) -> bool { + matches!( + sel, + Sel::Edge { .. } + | Sel::Face { .. } + | Sel::Vertex { .. } + | Sel::SketchCurve { .. } + | Sel::SketchPoint { .. } + | Sel::ExternalSketchCurve { .. } + | Sel::ExternalSketchPoint { .. } + | Sel::Profile { .. } + ) +} + +/// Each frame: start measuring a selection that changed, take a measurement that is in. +pub(crate) fn poll(app: &mut SolveApp, ctx: &egui::Context) { + let selection: Vec = app.session.selection.iter().filter(|s| measurable(s)).cloned().collect(); + if !wanted(app) || selection.is_empty() || selection.len() != app.session.selection.len() { + app.quick_measure = QuickMeasure::default(); + return; + } + let key = Key { selection, doc: std::sync::Arc::as_ptr(&app.session.doc) as usize, revision: app.session.revision }; + if app.quick_measure.key.as_ref() != Some(&key) { + app.quick_measure = QuickMeasure { job: start(app, &key.selection), key: Some(key), result: None }; + } + if let Some(job) = &app.quick_measure.job { + match job.try_recv() { + Ok(result) => { + app.quick_measure.result = result; + app.quick_measure.job = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(30)), + Err(std::sync::mpsc::TryRecvError::Disconnected) => app.quick_measure.job = None, + } + } +} + +/// Measure `selection` on a copy of the session, off the window thread. +fn start(app: &SolveApp, selection: &[Sel]) -> Option>> { + let session = app.session.scratch(); + let selection = selection.to_vec(); + let (tx, rx) = std::sync::mpsc::channel(); + let measure = move || { + let _ = tx.send(measure(&session, &selection)); + }; + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::Builder::new().name("quick measure".into()).spawn(measure).ok()?; + } + #[cfg(target_arch = "wasm32")] + measure(); + Some(rx) +} + +/// One or two items as the Measure tool measures them; more, their count and total length (edges +/// and sketch curves) or area (faces and sketch regions). `None`: nothing to show. +fn measure(session: &solidcraft_engine::Session, selection: &[Sel]) -> Option { + if selection.len() <= 2 { + let r = session.measure_items(selection); + return r.get("error").is_none().then_some(r); + } + let lengths = selection.iter().all(|s| matches!(s, Sel::Edge { .. } | Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. })); + let areas = selection.iter().all(|s| matches!(s, Sel::Face { .. } | Sel::Profile { .. })); + if !(lengths || areas) || selection.len() > MAX_SUMMED { + return None; + } + let mut total = 0.0; + for sel in selection { + let r = session.measure_items(std::slice::from_ref(sel)); + let it = r.get("items").and_then(|items| items.get(0))?; + total += if areas { + it["area_mm2"].as_f64()? + } else { + it["length_mm"].as_f64().or_else(|| it["circumference"].as_f64()).or_else(|| it["arc_length"].as_f64())? + }; + } + Some(json!({"count": selection.len(), if areas { "total_area_mm2" } else { "total_length_mm" }: total})) +} + +/// One measured item's rows, each "label value": Ø, R and the length of a round one (its +/// circumference, or an arc's length and angle), a slot's sizes, a straight edge's length, a face's +/// area, a point's position. The Measure tool lists the same. +pub(crate) fn item_lines(app: &SolveApp, it: &Value) -> Vec { + let tr = crate::i18n::tr; + let mm = |v: &Value| v.as_f64().map(|x| crate::prefs::show_mm(app, x, 1)).unwrap_or_default(); + let mut lines: Vec = Vec::new(); + if it.get("diameter").is_some() { + lines.push(format!("Ø {}", mm(&it["diameter"]))); + lines.push(format!("R {}", mm(&it["radius"]))); + if it.get("circumference").is_some() { + lines.push(format!("{} {}", tr("Circumference"), mm(&it["circumference"]))); + } else if let Some(a) = it["angle_deg"].as_f64() { + lines.push(format!("{} {}", tr("Arc length"), mm(&it["arc_length"]))); + lines.push(format!("{} {a:.2}°", crate::i18n::tr_label("Angle"))); + } + } + if let Some(sl) = it.get("slot") { + lines.push(format!("{} {}", tr("Slot width"), mm(&sl["width"]))); + lines.push(format!("{} {}", tr("Centre to centre"), mm(&sl["center_distance"]))); + lines.push(format!("{} {}", tr("Overall length"), mm(&sl["overall_length"]))); + } + let round = it.get("diameter").is_some(); + match it["type"].as_str() { + Some("edge" | "sketch_curve") if !round => lines.push(format!("{} {}", tr("Length"), mm(&it["length_mm"]))), + Some("face" | "profile") => lines.push(format!("{} {}", tr("Area"), crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2))), + Some("vertex" | "sketch_point") => { + let p = &it["position"]; + lines.push(format!("{} {}, {}, {}", tr("measure.at"), mm(&p[0]), mm(&p[1]), mm(&p[2]))); + } + _ => {} + } + lines +} + +/// The readout's rows for a measurement. +fn lines(app: &SolveApp, r: &Value) -> Vec<(String, String)> { + let split = |line: String| match line.split_once(" ") { + Some((label, value)) => (label.to_string(), value.to_string()), + None => (String::new(), line), + }; + if let Some(n) = r["count"].as_u64() { + let n = n.to_string(); + return match (r["total_length_mm"].as_f64(), r["total_area_mm2"].as_f64()) { + (Some(l), _) => vec![(crate::i18n::format("measure.quick_edges", &[("count", &n)]), crate::prefs::show_mm(app, l, 1))], + (_, Some(a)) => vec![(crate::i18n::format("measure.quick_faces", &[("count", &n)]), crate::prefs::show_mm(app, a, 2))], + _ => Vec::new(), + }; + } + let items = r["items"].as_array().map(Vec::as_slice).unwrap_or_default(); + if let [one] = items { + return item_lines(app, one).into_iter().map(split).collect(); + } + let mut rows = Vec::new(); + if let Some(d) = r["center_distance"].as_f64() { + rows.push((crate::i18n::tr("Centre to centre").to_string(), crate::prefs::show_mm(app, d, 1))); + } + if let Some(d) = r["distance_mm"].as_f64() { + rows.push((crate::i18n::tr_label("Distance").to_string(), crate::prefs::show_mm(app, d, 1))); + } + if let Some(a) = r["angle_deg"].as_f64() { + rows.push((crate::i18n::tr_label("Angle").to_string(), format!("{a:.2}°"))); + } + rows +} + +/// The readout in the bottom right corner of the view, above History, kept clear of `taken` (the +/// panels and cards over the view, the navigation bar): left of one standing along the right edge, +/// above any other. Returns where it is. +pub(crate) fn show(app: &mut SolveApp, ui: &egui::Ui, view: egui::Rect, taken: &[egui::Rect]) -> Option { + let rows = app.quick_measure.lines(app); + if rows.is_empty() || !view.is_finite() || !view.is_positive() { + return None; + } + let t = crate::theme::Tokens::get(); + let painter = ui.painter().clone(); + let font = egui::FontId::proportional(t.font_label); + let pad = egui::vec2(t.space_8, t.space_4); + let gap = t.space_8; + let galleys: Vec<_> = rows + .iter() + .map(|(label, value)| { + (painter.layout_no_wrap(label.clone(), font.clone(), t.text_dim), painter.layout_no_wrap(value.clone(), font.clone(), t.text)) + }) + .collect(); + let label_w = galleys.iter().map(|(l, _)| l.size().x).fold(0.0, f32::max); + let value_w = galleys.iter().map(|(_, v)| v.size().x).fold(0.0, f32::max); + let row_h = galleys.iter().map(|(l, v)| l.size().y.max(v.size().y)).fold(0.0, f32::max); + let size = egui::vec2(pad.x * 2.0 + label_w + gap + value_w, pad.y * 2.0 + row_h * galleys.len() as f32); + let mut r = egui::Rect::from_min_size(view.right_bottom() - size - egui::vec2(t.space_8, t.space_8), size); + // Each step clears one rectangle, so this ends after at most two moves per one. + for _ in 0..=2 * taken.len() { + let Some(o) = taken.iter().find(|o| o.expand(t.space_4).intersects(r)) else { break }; + r = if o.height() > view.height() * 0.5 { + r.translate(egui::vec2(o.left() - t.space_8 - r.right(), 0.0)) + } else { + r.translate(egui::vec2(0.0, o.top() - t.space_8 - r.bottom())) + }; + } + if !view.contains_rect(r) { + return None; + } + let response = ui.interact(r, egui::Id::new("sc_quick_measure"), egui::Sense::click()).on_hover_text(crate::i18n::tr("measure.quick_copy")); + crate::scenario::publish_rect("measure:quick", r); + painter.rect_filled(r, t.radius_sm, if response.hovered() { t.hover } else { t.overlay }); + painter.rect_stroke(r, t.radius_sm, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + for (i, (label, value)) in galleys.into_iter().enumerate() { + let y = r.top() + pad.y + row_h * i as f32; + painter.galley(egui::pos2(r.left() + pad.x, y), label, t.text_dim); + painter.galley(egui::pos2(r.right() - pad.x - value.size().x, y), value, t.text); + } + if response.clicked() { + let text = rows + .iter() + .map(|(label, value)| if label.is_empty() { value.clone() } else { format!("{label} {value}") }) + .collect::>() + .join("\n"); + ui.ctx().copy_text(text); + app.set_status(crate::i18n::tr("measure.quick_copied"), false); + } + Some(r) +} diff --git a/crates/ui-egui/src/quick_measure_tests.rs b/crates/ui-egui/src/quick_measure_tests.rs new file mode 100644 index 0000000000..da554a7762 --- /dev/null +++ b/crates/ui-egui/src/quick_measure_tests.rs @@ -0,0 +1,210 @@ +//! Quick measurement in the view's corner (#7000): what the selection measures, computed off the +//! window thread, shown above History, copied on a click. + +use serde_json::json; +use solidcraft_engine::Sel; +use solidcraft_engine::geom::Vec3; + +use crate::scenario::Harness; + +/// A design shown, with quick measurement turned on. +fn harness() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.quick_measure = true; + h +} + +/// A 10 × 20 × 30 box at the origin. +fn block() -> Harness { + let mut h = harness(); + h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30})).unwrap(); + h.frames(2); + h +} + +fn edge(h: &Harness, p: Vec3) -> Sel { + let st = h.app.session.world_state(); + let m = st.body("Body1").unwrap().mesh(); + let index = m.edges.iter().position(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-9)).expect("an edge there"); + Sel::Edge { body: "Body1".into(), index, point: p } +} + +/// The face with a triangle wholly at height `z`. +fn face_at_height(h: &Harness, z: f64) -> Sel { + let st = h.app.session.world_state(); + let m = st.body("Body1").unwrap().mesh(); + let (t, f) = m.triangles.iter().zip(&m.tri_face).find(|(t, _)| m.tri(t).is_some_and(|ps| ps.iter().all(|p| (p.z - z).abs() < 1e-9))).unwrap(); + let ps = m.tri(t).unwrap(); + Sel::Face { body: "Body1".into(), index: *f as usize, point: (ps[0] + ps[1] + ps[2]) * (1.0 / 3.0) } +} + +/// The readout once its measurement is in (empty if none comes). +fn readout(h: &mut Harness) -> Vec<(String, String)> { + for _ in 0..300 { + // As each frame does before drawing it: a changed selection is measured afresh. + crate::quick_measure::poll(&mut h.app, &h.ctx); + let rows = h.app.quick_measure.lines(&h.app); + if !rows.is_empty() { + return rows; + } + h.frames(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Vec::new() +} + +fn row(label: &str, value: String) -> (String, String) { + (label.to_string(), value) +} + +#[test] +fn an_edge_shows_its_length() { + let mut h = block(); + h.app.session.selection = vec![edge(&h, Vec3::new(5.0, 0.0, 0.0))]; + let length = crate::prefs::show_mm(&h.app, 10.0, 1); + assert_eq!(readout(&mut h), vec![row("Length", length)]); +} + +#[test] +fn a_round_edge_shows_its_diameter_radius_and_circumference() { + let mut h = harness(); + h.app.run("solid.cylinder", json!({"radius": 5, "height": 10})).unwrap(); + h.frames(2); + // The top circle, by a point between two of its polyline's points (its first is on the seam). + let on_rim = { + let st = h.app.session.world_state(); + let m = st.body("Body1").unwrap().mesh(); + let rim = m.edges.iter().find(|e| e.len() > 2 && e.iter().all(|p| (p.z - 10.0).abs() < 1e-9)).expect("the top circle"); + (rim[1] + rim[2]) * 0.5 + }; + h.app.session.selection = vec![edge(&h, on_rim)]; + let rows = readout(&mut h); + assert_eq!(rows.first(), Some(&row("Ø", crate::prefs::show_mm(&h.app, 10.0, 1))), "{rows:?}"); + assert_eq!(rows.get(1), Some(&row("R", crate::prefs::show_mm(&h.app, 5.0, 1))), "{rows:?}"); + assert!(rows.iter().any(|(l, _)| l == "Circumference"), "{rows:?}"); +} + +#[test] +fn a_face_shows_its_area() { + let mut h = block(); + h.app.session.selection = vec![face_at_height(&h, 30.0)]; + assert_eq!(readout(&mut h), vec![row("Area", crate::prefs::show_mm(&h.app, 200.0, 2))]); +} + +#[test] +fn a_sketch_line_shows_its_length() { + let mut h = harness(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.line", json!({"points": [[0, 0], [30, 40]]})).unwrap(); + h.app.session.selection = vec![Sel::SketchCurve { id: "l1".into(), sketch: None }]; + assert_eq!(readout(&mut h), vec![row("Length", crate::prefs::show_mm(&h.app, 50.0, 1))]); +} + +/// A sketch that is only shown, not open: a click picks its curves, points and regions, and they +/// measure as they do in the sketch. +#[test] +fn a_shown_sketchs_line_point_and_region_measure_too() { + let mut h = harness(); + let sketch = h.app.run("sketch.create", json!({"plane": "XY"})).unwrap()["sketch"].as_u64().unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 40]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + assert_eq!(h.app.session.active_sketch, None); + let sk = h.app.session.doc.sketch(sketch).unwrap(); + let side = sk.curves.first().unwrap().id.clone(); + let corner = sk.points.first().unwrap().id.clone(); + h.app.session.selection = vec![Sel::ExternalSketchCurve { sketch, id: side }]; + let rows = readout(&mut h); + assert!(rows.iter().any(|(l, v)| l == "Length" && [30.0, 40.0].iter().any(|n| *v == crate::prefs::show_mm(&h.app, *n, 1))), "{rows:?}"); + h.app.session.selection = vec![Sel::ExternalSketchPoint { sketch, id: corner }]; + assert!(readout(&mut h).iter().any(|(l, _)| l == "At"), "a point's position"); + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + assert_eq!(readout(&mut h), vec![row("Area", crate::prefs::show_mm(&h.app, 1200.0, 2))]); +} + +#[test] +fn two_points_show_their_distance() { + let mut h = block(); + h.app.session.selection = [[0.0, 0.0, 0.0], [10.0, 20.0, 30.0]].map(|p| Sel::Vertex { body: "Body1".into(), point: p.into() }).to_vec(); + let rows = readout(&mut h); + assert!(rows.contains(&row("Distance", crate::prefs::show_mm(&h.app, 1400f64.sqrt(), 1))), "{rows:?}"); +} + +#[test] +fn several_edges_show_their_count_and_total_length() { + let mut h = block(); + h.app.session.selection = [[5.0, 0.0, 0.0], [5.0, 20.0, 0.0], [5.0, 0.0, 30.0]].map(|[x, y, z]| edge(&h, Vec3::new(x, y, z))).to_vec(); + assert_eq!(readout(&mut h), vec![row("3 edges", crate::prefs::show_mm(&h.app, 30.0, 1))]); +} + +/// Off by default: nothing shows until Preferences › Interface turns it on. +#[test] +fn quick_measurement_is_off_until_turned_on() { + assert!(!crate::prefs::Prefs::default().quick_measure); + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30})).unwrap(); + h.frames(2); + assert!(!h.app.preferences.quick_measure); + h.app.session.selection = vec![edge(&h, Vec3::new(5.0, 0.0, 0.0))]; + assert!(readout(&mut h).is_empty(), "off by default"); + h.frames(2); + assert!(crate::scenario::rect_of("measure:quick").is_none(), "nothing drawn"); + h.call("ui.prefs", json!({"quick_measure": true})); + assert_eq!(readout(&mut h), vec![row("Length", crate::prefs::show_mm(&h.app, 10.0, 1))]); +} + +/// Nothing shows with the preference off, with nothing selected, for something with nothing to +/// measure (a body) or while the Measure tool, which says the same, is open. +#[test] +fn the_readout_stays_away_when_it_has_nothing_to_add() { + let mut h = block(); + let e = edge(&h, Vec3::new(5.0, 0.0, 0.0)); + h.app.session.selection = vec![e.clone()]; + assert!(!readout(&mut h).is_empty()); + h.call("ui.prefs", json!({"quick_measure": false})); + h.frames(2); + assert!(h.app.quick_measure.lines(&h.app).is_empty(), "preference off"); + h.call("ui.prefs", json!({"quick_measure": true})); + h.app.session.selection.clear(); + h.frames(2); + assert!(h.app.quick_measure.lines(&h.app).is_empty(), "nothing selected"); + h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + h.frames(2); + assert!(readout(&mut h).is_empty(), "a body"); + h.app.session.selection = vec![e]; + h.app.start("inspect.measure"); + h.frames(2); + assert!(h.app.quick_measure.lines(&h.app).is_empty(), "the Measure tool is open"); +} + +/// The readout sits in the view's bottom right corner above History; a click on it copies it. +#[test] +fn the_readout_sits_above_history_and_a_click_copies_it() { + let mut h = block(); + h.app.session.selection = vec![edge(&h, Vec3::new(5.0, 0.0, 0.0))]; + assert!(!readout(&mut h).is_empty()); + h.frames(2); + let r = crate::scenario::rect_of("measure:quick").expect("the readout is drawn"); + let history = h.app.shell.history.expect("History is shown"); + assert!(r.bottom() <= history.top(), "{r:?} above History {history:?}"); + assert!(r.right() > 1600.0 * 0.6, "{r:?} on the right"); + let at = r.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.frame_with(vec![egui::Event::PointerMoved(at)]); + h.frame_with(vec![button(true)]); + h.frame_with(vec![button(false)]); + let copied = h.output.commands.iter().find_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }); + assert_eq!(copied, Some(format!("Length {}", crate::prefs::show_mm(&h.app, 10.0, 1)))); +} + +/// The measurement is computed off the window thread and lands a frame or so after the selection +/// changes. The thread may already be done when the first poll looks, so only the arrival is +/// asserted, never that it is still coming. +#[test] +fn the_measurement_computes_off_the_window_thread() { + let mut h = block(); + h.app.session.selection = vec![edge(&h, Vec3::new(5.0, 0.0, 0.0))]; + crate::quick_measure::poll(&mut h.app, &h.ctx); + assert!(!readout(&mut h).is_empty(), "then in"); +} diff --git a/crates/ui-egui/src/recovery_ui.rs b/crates/ui-egui/src/recovery_ui.rs index 4416c9abf2..1c48513097 100644 --- a/crates/ui-egui/src/recovery_ui.rs +++ b/crates/ui-egui/src/recovery_ui.rs @@ -125,33 +125,53 @@ pub(crate) fn ago(secs: u64) -> String { pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // File view presents the same entries inline, without a second window covering them. if app.home.open || app.recoverable.is_empty() { + app.recovery_discard_all = false; + return; + } + if app.recovery_discard_all { + confirm_discard_all(app, ctx); return; } let mut act: Option<(Entry, bool)> = None; + let mut discard_all = false; let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_recover_prompt"), "restore", crate::i18n::tr_label("Recover unsaved design?")) .actions("", crate::i18n::tr_label("Later"), None) - .show(ctx, true, |ui| { - ui_kit::text( - ui, - crate::i18n::tr_label("SolidCraft closed before these designs were saved. Their last autosave can be opened."), - ui_kit::Text::Report("intro", false), - ); - for (index, e) in app.recoverable.iter().take(20).enumerate() { - ui_kit::divider(ui, &format!("entry:{index}")); - ui_kit::label_actions(ui, &e.name, |ui| { - if ui_kit::button(ui, crate::i18n::tr_label("Recover"), ui_kit::Button::Normal).clicked() { - act = Some((e.clone(), true)); - } - if ui_kit::button(ui, crate::i18n::tr_label("Discard"), ui_kit::Button::Normal).clicked() { - act = Some((e.clone(), false)); - } - }); - let file = e.path.clone().unwrap_or_else(|| crate::i18n::tr("recovery.never_saved").to_string()); - let detail = - crate::i18n::format("recovery.detail", &[("file", &file), ("features", &e.features.to_string()), ("when", &ago(e.saved_at))]); - ui_kit::text(ui, &detail, ui_kit::Text::Note(&format!("detail:{index}"))); - } - }); + .show_with_left( + ctx, + true, + |ui| { + ui_kit::text( + ui, + crate::i18n::tr_label("SolidCraft closed before these designs were saved. Their last autosave can be opened."), + ui_kit::Text::Report("intro", false), + ); + for (index, e) in app.recoverable.iter().take(20).enumerate() { + ui_kit::divider(ui, &format!("entry:{index}")); + ui_kit::label_actions(ui, &e.name, |ui| { + if ui_kit::button(ui, crate::i18n::tr_label("Recover"), ui_kit::Button::Normal).clicked() { + act = Some((e.clone(), true)); + } + if ui_kit::button(ui, crate::i18n::tr_label("Discard"), ui_kit::Button::Normal).clicked() { + act = Some((e.clone(), false)); + } + }); + let file = e.path.clone().unwrap_or_else(|| crate::i18n::tr("recovery.never_saved").to_string()); + let detail = + crate::i18n::format("recovery.detail", &[("file", &file), ("features", &e.features.to_string()), ("when", &ago(e.saved_at))]); + ui_kit::text(ui, &detail, ui_kit::Text::Note(&format!("detail:{index}"))); + } + }, + // Away from Later: it asks first, then discards every listed autosave (#9299). + |ui| { + let response = ui_kit::button(ui, crate::i18n::tr("recovery.discard_all"), ui_kit::Button::Normal); + crate::scenario::publish_target("recovery:discard_all", response.rect); + discard_all = response.clicked(); + }, + ); + if discard_all { + app.recovery_discard_all = true; + return; + } let later = closed || action != ui_kit::Action::None; if let Some((entry, recover)) = act { apply(app, entry, recover); @@ -161,6 +181,38 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } +/// Discard all asks first; Cancel (or the close button) goes back to the list unchanged. +fn confirm_discard_all(app: &mut SolveApp, ctx: &egui::Context) { + let count = app.recoverable.len(); + let title = if count == 1 { + crate::i18n::tr("recovery.discard_all_title_one").to_string() + } else { + crate::i18n::format("recovery.discard_all_title", &[("count", &count.to_string())]) + }; + let (closed, action) = ui_kit::Confirm::new(egui::Id::new("sc_recover_discard_all"), "delete", &title) + .tone(ui_kit::Tone::Danger) + .actions(crate::i18n::tr("recovery.discard_all"), crate::i18n::tr("ui.cancel"), None) + .show(ctx, true, |ui| { + ui_kit::text(ui, crate::i18n::tr("recovery.discard_all_detail"), ui_kit::Text::Report("discard_all", false)); + }); + match action { + ui_kit::Action::Ok => { + app.recovery_discard_all = false; + discard_all(app); + } + ui_kit::Action::Cancel => app.recovery_discard_all = false, + _ if closed => app.recovery_discard_all = false, + _ => {} + } +} + +/// Discard every listed autosave, each the way its own Discard button does. +fn discard_all(app: &mut SolveApp) { + for entry in std::mem::take(&mut app.recoverable) { + apply(app, entry, false); + } +} + /// Shared recovery action for the launch prompt and File view; dirty work is never replaced. pub(crate) fn apply(app: &mut SolveApp, entry: Entry, recover: bool) { let dir_s = app.autosave.as_ref().map(|a| a.dir.clone()).or_else(recovery::default_dir).map(|d| d.to_string_lossy().to_string()); @@ -492,4 +544,50 @@ mod tests { h.frames(2); assert!(h.app.recoverable.is_empty(), "Later hides the card for this launch"); } + + /// #9299: Discard all asks first; Cancel keeps every entry, confirming discards them all. + #[test] + fn discard_all_asks_first_then_discards_every_listed_autosave() { + let (mut crashed, dir) = app("discard-all", Duration::ZERO); + for i in 0..3 { + if i > 0 { + documents::new_tab(&mut crashed); + } + name(&mut crashed, &format!("Design {i}")); + edit(&mut crashed); + } + tick_and_wait(&mut crashed); + drop(crashed); + assert_eq!(recovery::orphans(&dir).len(), 3); + + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + start(&mut h.app, &dir, Duration::from_secs(3600)); + h.frames(4); + assert_eq!(h.app.recoverable.len(), 3); + assert!(crate::scenario::handle_at("recovery:discard_all").is_some()); + h.step(&json!({"widget": "Discard all"})).unwrap(); + h.frames(2); + assert!(h.app.recovery_discard_all, "Discard all opens its confirmation"); + assert!(h.widget("Cancel").is_some() && h.widget("Discard all").is_some(), "a confirmation first"); + assert!(h.widget("Discard 3 autosaves?").is_some(), "the title counts the autosaves"); + assert!(h.widget("Later").is_none(), "the confirmation replaces the list"); + assert_eq!(recovery::orphans(&dir).len(), 3, "nothing discarded before the answer"); + h.step(&json!({"widget": "Cancel"})).unwrap(); + h.frames(2); + assert!(h.widget("Later").is_some(), "Cancel goes back to the list"); + assert_eq!(h.app.recoverable.len(), 3); + assert_eq!(recovery::orphans(&dir).len(), 3); + + h.step(&json!({"widget": "Discard all"})).unwrap(); + h.frames(2); + h.step(&json!({"widget": "Discard all"})).unwrap(); + h.frames(2); + assert!(h.app.recoverable.is_empty()); + assert!(!h.app.recovery_discard_all); + assert!(recovery::orphans(&dir).is_empty(), "every listed autosave is gone"); + assert!(h.widget("Later").is_none()); + drop(h); + let _ = fs::remove_dir_all(dir); + } } diff --git a/crates/ui-egui/src/render_ui/assign.rs b/crates/ui-egui/src/render_ui/assign.rs index b94c55053a..ae80e0694b 100644 --- a/crates/ui-egui/src/render_ui/assign.rs +++ b/crates/ui-egui/src/render_ui/assign.rs @@ -531,8 +531,8 @@ pub(crate) fn selection_bar(app: &mut SolveApp, ui: &mut egui::Ui, free: egui::R }) .collect::>(); let anchor = egui::pos2( - screen.center().x.clamp(free.left(), free.right()), - (screen.top() - crate::theme::Tokens::get().space_12).clamp(free.top(), free.bottom()), + crate::shell::within(screen.center().x, free.left(), free.right()), + crate::shell::within(screen.top() - crate::theme::Tokens::get().space_12, free.top(), free.bottom()), ); let ctx = ui.ctx().clone(); let pill = ui_kit::Container::selection_card(&ctx, egui::Id::new("render_selection"), anchor, free, |ui| { diff --git a/crates/ui-egui/src/render_ui/mod.rs b/crates/ui-egui/src/render_ui/mod.rs index 3634f92431..4b6b9d4c1b 100644 --- a/crates/ui-egui/src/render_ui/mod.rs +++ b/crates/ui-egui/src/render_ui/mod.rs @@ -85,7 +85,7 @@ fn stage_tools(app: &mut SolveApp, ui: &mut egui::Ui) { Stage::Look => { ui_kit::StageBar::caption(ui, app.tr("render.pick_label")); let modes = ["body", "face", "component"]; - let mut selected = modes.iter().position(|mode| *mode == current_pick_mode(app)).unwrap_or(0); + let mut selected = modes.iter().position(|mode| *mode == app.ui.pick_mode()).unwrap_or(0); let pick = ui.add_enabled_ui(!crate::render_workspace::result_active(app), |ui| { if ui_kit::segmented( ui, @@ -201,7 +201,7 @@ pub(crate) fn overlays(app: &mut SolveApp, ui: &mut egui::Ui) { if !focused(app) { shots_pill(app, ui, free); } - crate::viewport::transient_messages(app, ui, viewport); + crate::viewport::transient_messages(app, ui, viewport, None); } /// Shared kit chip at the Browser's remembered side, below the stage bar. @@ -225,16 +225,6 @@ pub(crate) fn dock_chip(app: &mut SolveApp, ui: &mut egui::Ui, id: &'static str, crate::shell::register_panel(app, id, Some(chip.response.rect), None); } -fn current_pick_mode(app: &SolveApp) -> &'static str { - if app.ui.pick_components { - "component" - } else if app.ui.pick_bodies { - "body" - } else { - "face" - } -} - fn shots_pill(app: &mut SolveApp, ui: &mut egui::Ui, free: Rect) { crate::shell::register_panel(app, "render:shots:show", None, None); if app.preferences.render_shots_strip { diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 511d15bea3..b09ab22aff 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -652,22 +652,26 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> { return Err(crate::i18n::tr("render.error_finish_operation").into()); } app.run("render.scene", json!({}))?; - if !active(app) { + let entering = !active(app); + if entering { app.render_workspace.design_camera = Some((app.cam, app.ui.perspective)); app.ui.tab = "RENDER".into(); select_stage(app, app.render_workspace.view.stage.key()); if app.render_workspace.view.selection == RenderSelection::Nothing { selection_editor(app, &RenderSelection::Nothing); } - let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?; - if !scene.bounds.is_empty() { - app.fit_bounds(scene.bounds); - } } setups::refresh_dirty(app); if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty { setups::load(app); } + // Fit once the setup in effect is known: the fit frames that image's aspect. + if entering { + let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?; + if !scene.bounds.is_empty() { + app.fit_bounds(scene.bounds); + } + } #[cfg(not(target_arch = "wasm32"))] app.render_workspace.start_deferred_path_host(); } else { @@ -1908,6 +1912,20 @@ fn hash_surface(surface: Option f64 { + f64::from(setup.width.max(1)) / f64::from(setup.height.max(1)) +} + +/// `layout` seen from the picture: while Frame shows the picture's frame the preview projects +/// into that frame, not the viewport, so a fit that keeps the product inside this layout keeps +/// it inside the image. Elsewhere the preview fills the viewport and the layout is unchanged. +pub(crate) fn picture_layout(app: &SolveApp, layout: &crate::viewport_layout::Layout) -> crate::viewport_layout::Layout { + let Some(frame) = shot::preview_rect(app, layout.viewport) else { return layout.clone() }; + // The same 16-point margin the viewport's fit keeps inside its free area. + crate::viewport_layout::Layout { viewport: frame, drawable: layout.drawable.intersect(frame.shrink(16.0)), ..layout.clone() } +} + /// The size the product preview draws at for a viewport of `full` physical pixels: the full /// size when the viewport executor admits it, else the largest size at the same aspect it does. /// Without an executor the request is left for resolution to report. @@ -2058,7 +2076,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: [0.0; 36], access: None, diff --git a/crates/ui-egui/src/render_workspace_cpu.rs b/crates/ui-egui/src/render_workspace_cpu.rs index 694467ea16..9251f1c1c1 100644 --- a/crates/ui-egui/src/render_workspace_cpu.rs +++ b/crates/ui-egui/src/render_workspace_cpu.rs @@ -6,9 +6,19 @@ use super::hdr; use crate::{SolveApp, ui_kit}; pub(super) fn validate_progressive_image(app: &SolveApp) -> Result<(), String> { - if app.render_workspace.view.method == super::ImageMethod::CpuRaytrace { - let view = &app.render_workspace.view; - view.raytrace.budget.validate_image(view.draft.width, view.draft.height, view.raytrace.progressive).map_err(|error| error.to_string())?; + let view = &app.render_workspace.view; + if view.method == super::ImageMethod::GpuRaytrace { + return Ok(()); + } + // A size refusal is shown translated, naming the profile that can render it (#8941). Both CPU + // methods stop here, before the English refusals of the render and document crates. + let (width, height) = (view.draft.width, view.draft.height); + let size = (u64::from(width), u64::from(height)); + if width > 0 && height > 0 && image_limits(app).fit(size.0, size.1, None) != Some(size) { + return Err(super::inspector::size_limit_reason(app, width, height)); + } + if view.method == super::ImageMethod::CpuRaytrace { + view.raytrace.budget.validate_image(width, height, view.raytrace.progressive).map_err(|error| error.to_string())?; } Ok(()) } @@ -28,6 +38,22 @@ pub(super) fn image_limits(app: &SolveApp) -> solidcraft_engine::render::RenderL limits } +/// For a size a CPU method refuses, the smallest CPU ray tracing profile that can render it, to +/// name in a hint. `None` for GPU path tracing, for sizes that fit, and past every profile. CPU +/// raster has no profiles of its own, so its hint also names CPU ray tracing. Neither the method +/// nor the profile is switched here: they and the resolution change only when chosen. +pub(super) fn profile_for_size(app: &SolveApp, width: u64, height: u64) -> Option { + if app.render_workspace.view.method == super::ImageMethod::GpuRaytrace || image_limits(app).fit(width, height, None) == Some((width, height)) { + return None; + } + CpuBudget::smallest_admitting(u32::try_from(width).ok()?, u32::try_from(height).ok()?, app.render_workspace.view.raytrace.progressive) +} + +/// The translated name of a profile, as the profile selector shows it. +pub(super) fn budget_name(app: &SolveApp, budget: CpuBudget) -> &str { + app.tr(budget_key(budget)) +} + fn budget_key(budget: CpuBudget) -> &'static str { match budget { CpuBudget::Compact => "render.children.compact", diff --git a/crates/ui-egui/src/render_workspace_inspector.rs b/crates/ui-egui/src/render_workspace_inspector.rs index a3ba0c7214..ffc21d715b 100644 --- a/crates/ui-egui/src/render_workspace_inspector.rs +++ b/crates/ui-egui/src/render_workspace_inspector.rs @@ -854,13 +854,23 @@ fn output_controls(app: &mut SolveApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { for (key, label, size) in choices { let allowed = size.filter(|&(w, h)| cpu::image_limits(app).fit(w, h, None) == Some((w, h))); + let mark = size_mark(app, label, *size); let response = ui .add_enabled_ui(allowed.is_some(), |ui| { - ui_kit::choice_chip(ui, key, label, *size == Some((u64::from(before[0]), u64::from(before[1])))) + ui_kit::choice_chip( + ui, + key, + mark.as_deref().unwrap_or(label), + *size == Some((u64::from(before[0]), u64::from(before[1]))), + ) }) .inner; crate::scenario::publish_target(&format!("render:size:{key}"), response.rect); - if response.on_disabled_hover_text(app.tr("render.output_over_budget")).clicked() + let reason = match (mark.is_some(), *size) { + (true, Some((w, h))) => size_limit_reason(app, u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX)), + _ => app.tr("render.output_over_budget").to_string(), + }; + if response.on_disabled_hover_text(reason).clicked() && let Some((w, h)) = allowed && let (Ok(w), Ok(h)) = (u32::try_from(w), u32::try_from(h)) && let Err(error) = apply_size(app, w, h, false) @@ -1248,10 +1258,10 @@ fn apply_size(app: &mut SolveApp, width: u32, height: u32, custom: bool) -> Resu app.render_workspace.view.dirty = true; Ok(()) } -fn size_limit_reason(app: &SolveApp, width: u32, height: u32) -> String { +pub(super) fn size_limit_reason(app: &SolveApp, width: u32, height: u32) -> String { let limits = cpu::image_limits(app); let (w, h) = limits.fit(u64::from(width), u64::from(height), None).unwrap_or((0, 0)); - crate::i18n::format( + let reason = crate::i18n::format( "render.output_size_limit", &[ ("method", app.tr(app.render_workspace.view.method.label())), @@ -1260,7 +1270,24 @@ fn size_limit_reason(app: &SolveApp, width: u32, height: u32) -> String { ("width", &w.to_string()), ("height", &h.to_string()), ], - ) + ); + let Some(profile) = cpu::profile_for_size(app, u64::from(width), u64::from(height)) else { + return reason; + }; + let (profile, method) = (cpu::budget_name(app, profile), app.tr(ImageMethod::CpuRaytrace.label())); + let (width, height) = (width.to_string(), height.to_string()); + let args = [("profile", profile), ("method", method), ("width", width.as_str()), ("height", height.as_str())]; + // CPU raster has no profile selector: the way out includes changing the method. + let key = + if app.render_workspace.view.method == ImageMethod::Raster { "render.output_size_method_profile" } else { "render.output_size_profile" }; + format!("{reason} {}", crate::i18n::format(key, &args)) +} +/// A preset a CPU method refuses carries the profile that renders it (#8941). Kept short for the +/// narrow size lists; the reason in its tooltip says what to choose, method included. +pub(super) fn size_mark(app: &SolveApp, text: &str, size: Option<(u64, u64)>) -> Option { + let (width, height) = size?; + let profile = cpu::budget_name(app, cpu::profile_for_size(app, width, height)?); + Some(crate::i18n::format("render.size_needs_profile", &[("size", text), ("profile", profile)])) } pub(super) fn size_presets(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::scope(ui, |ui| { @@ -1268,6 +1295,7 @@ pub(super) fn size_presets(app: &mut SolveApp, ui: &mut egui::Ui) { let custom = app.render_workspace.view.inspector.resolution.custom || !SIZE_PRESETS.iter().any(|(_, _, size)| *size == Some(current)); for (key, label, size) in SIZE_PRESETS { let summary = size.map_or_else(String::new, |(w, h)| format!("{w} × {h}")); + let summary = size_mark(app, &summary, size.map(|(w, h)| (u64::from(w), u64::from(h)))).unwrap_or(summary); let enabled = size.is_none_or(|(w, h)| cpu::image_limits(app).fit(u64::from(w), u64::from(h), None) == Some((u64::from(w), u64::from(h)))); let response = ui @@ -1298,7 +1326,8 @@ pub(super) fn stage_tools(app: &mut SolveApp, ui: &mut egui::Ui) { for (key, label, size) in SIZE_PRESETS { let enabled = size.is_none_or(|(w, h)| cpu::image_limits(app).fit(u64::from(w), u64::from(h), None) == Some((u64::from(w), u64::from(h)))); - let response = ui.add_enabled_ui(enabled, |ui| ui_kit::list_picker(ui, key, app.tr(label), false)).inner; + let mark = size_mark(app, app.tr(label), size.map(|(w, h)| (u64::from(w), u64::from(h)))); + let response = ui.add_enabled_ui(enabled, |ui| ui_kit::list_picker(ui, key, mark.as_deref().unwrap_or(app.tr(label)), false)).inner; crate::scenario::publish_target(&format!("render:stage:size:{key}"), response.rect); let reason = size.map_or_else(String::new, |(w, h)| size_limit_reason(app, w, h)); if response.on_disabled_hover_text(reason).clicked() { @@ -1625,9 +1654,10 @@ fn base_appearance(app: &mut SolveApp, ui: &mut egui::Ui) { mod tests; pub(super) fn fit_subject(app: &mut SolveApp) -> Result<(), String> { - // Capture the current navigation orientation before the engine's view command. + // Capture the current navigation orientation before the engine's view command, which fits + // the product to the picture's aspect: what the preview frames and Export PNG writes. app.run("render.camera.set", json!({"camera":app.cam}))?; - app.run("render.camera.fit", json!({}))?; + app.run("render.camera.fit", json!({"aspect":super::image_aspect(&app.render_workspace.view.draft)}))?; app.follow_view_request(false); app.fit_view(); Ok(()) diff --git a/crates/ui-egui/src/render_workspace_inspector_tests.rs b/crates/ui-egui/src/render_workspace_inspector_tests.rs index 46f93ec8b5..b848b9f638 100644 --- a/crates/ui-egui/src/render_workspace_inspector_tests.rs +++ b/crates/ui-egui/src/render_workspace_inspector_tests.rs @@ -1624,6 +1624,110 @@ fn output_sections_sizes_refuse_hostile_input_and_4k_follows_the_selected_budget assert!(h.app.render_workspace.view.job.is_none()); } +/// #8941: the Shots strip offers 4K UHD whatever the method. Under the default CPU raster, which +/// has no profiles, the list and the refusal name CPU ray tracing with Studio; neither is selected. +#[test] +fn cpu_raster_refusing_a_size_names_the_method_and_profile_that_render_it() { + use solidcraft_engine::render::job::CpuBudget; + let mut h = fixture(); + assert_eq!(h.app.render_workspace.view.method, ImageMethod::Raster, "the default method is unchanged"); + let doc = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + h.step(&json!({"click":{"handle":"render:shots:size"}})).unwrap(); + h.step(&json!({"widget":"Full HD · 1920 × 1080"})).unwrap(); + assert_eq!((h.app.render_workspace.view.draft.width, h.app.render_workspace.view.draft.height), (1920, 1080)); + assert!(super::super::image_request(&h.app).is_ok()); + h.step(&json!({"click":{"handle":"render:shots:size"}})).unwrap(); + h.step(&json!({"widget":"4K UHD · 3840 × 2160 · needs Studio"})).unwrap(); + assert_eq!((h.app.render_workspace.view.draft.width, h.app.render_workspace.view.draft.height), (3840, 2160)); + let refusal = super::super::image_request(&h.app).unwrap_err(); + assert!(refusal.contains("Largest fitting size: 2730 × 1536."), "{refusal}"); + assert!(refusal.contains("To render 3840 × 2160, choose CPU ray tracing with the Studio CPU resource profile, or a smaller size."), "{refusal}"); + assert_eq!(refusal, size_limit_reason(&h.app, 3840, 2160)); + assert_eq!(h.app.render_workspace.view.method, ImageMethod::Raster); + assert_eq!(h.app.render_workspace.view.raytrace.budget, CpuBudget::Compact); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!(h.app.session.undo.len(), undo); + assert!(!size_limit_reason(&h.app, 16384, 16384).contains("CPU resource profile"), "past Workstation no profile is named"); + assert_eq!(size_mark(&h.app, "fits", Some((2048, 2048))), None); + h.app.preferences.language = "de".into(); + let _language = crate::i18n::scope("de"); + assert!(super::super::image_request(&h.app).unwrap_err().contains("CPU-Ressourcenprofil Studio")); +} + +/// #8941: under the default Compact CPU profile 4K UHD is refused with a translated reason that +/// names Studio, and the size lists mark only the presets the selected profile can't render. +/// Nothing switches the profile or the size; GPU path tracing shows no CPU profile. +#[test] +fn a_size_the_cpu_profile_refuses_names_the_profile_that_renders_it() { + use solidcraft_engine::render::job::CpuBudget; + let mut h = fixture(); + super::super::select_image_method(&mut h.app, ImageMethod::CpuRaytrace); + h.frames(3); + h.step(&json!({"click":{"handle":"render:shots:size"}})).unwrap(); + h.step(&json!({"widget":"4K UHD · 3840 × 2160 · needs Studio"})).unwrap(); + assert_eq!(h.app.render_workspace.view.raytrace.budget, CpuBudget::Compact, "the default profile is unchanged"); + let reason = size_limit_reason(&h.app, 3840, 2160); + assert!(reason.contains("Largest fitting size: 2730 × 1536."), "{reason}"); + assert!(reason.contains("To render 3840 × 2160, choose the Studio CPU resource profile, or a smaller size."), "{reason}"); + assert_eq!(size_mark(&h.app, "4K UHD", Some((3840, 2160))).as_deref(), Some("4K UHD · needs Studio")); + for fits in [(1280, 720), (1920, 1080), (2048, 2048)] { + assert_eq!(size_mark(&h.app, "fits", Some(fits)), None, "{fits:?}"); + } + assert_eq!(size_mark(&h.app, "custom", None), None); + assert_eq!(size_mark(&h.app, "past every profile", Some((16385, 16385))), None); + let reason = size_limit_reason(&h.app, 16384, 16384); + assert!(!reason.contains("CPU resource profile"), "past Workstation no profile is named: {reason}"); + + // Render and Check are refused with the same translated reason, and nothing is changed. + h.app.render_workspace.view.draft.width = 3840; + h.app.render_workspace.view.draft.height = 2160; + let doc = h.app.session.doc.clone(); + h.frames(3); + assert_eq!(cpu::validate_progressive_image(&h.app).unwrap_err(), size_limit_reason(&h.app, 3840, 2160)); + assert!(super::super::image_request(&h.app).unwrap_err().contains("choose the Studio CPU resource profile")); + assert_eq!(h.app.render_workspace.view.raytrace.budget, CpuBudget::Compact); + assert_eq!((h.app.render_workspace.view.draft.width, h.app.render_workspace.view.draft.height), (3840, 2160)); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + + // Studio renders 4K and marks nothing up to it; a 16K-wide image names Workstation. + h.app.render_workspace.view.raytrace.budget = CpuBudget::Studio; + cpu::validate_progressive_image(&h.app).unwrap(); + assert_eq!(size_mark(&h.app, "4K UHD", Some((3840, 2160))), None); + assert_eq!(size_mark(&h.app, "16K", Some((15360, 4320))).as_deref(), Some("16K · needs Workstation")); + assert!(size_limit_reason(&h.app, 15360, 4320).contains("choose the Workstation CPU resource profile")); + h.app.render_workspace.view.raytrace.budget = CpuBudget::Workstation; + assert_eq!(size_mark(&h.app, "16K", Some((15360, 4320))), None); + + // Progressive sampling's camera-sample budget counts: 4K at 64 spp needs Studio too. + h.app.render_workspace.view.raytrace.budget = CpuBudget::Compact; + h.app.render_workspace.view.raytrace.progressive = Some(solidcraft_engine::render::job::CpuProgressiveOptions { samples_per_pixel: 64, seed: 0 }); + assert_eq!(cpu::profile_for_size(&h.app, 3840, 2160), Some(CpuBudget::Studio)); + h.app.render_workspace.view.raytrace.progressive = None; + + // Translated: the profile is named as the profile selector names it. + h.app.preferences.language = "ja".into(); + { + let _language = crate::i18n::scope("ja"); + let reason = size_limit_reason(&h.app, 3840, 2160); + assert!(reason.contains("CPUリソースプロファイル「スタジオ」"), "{reason}"); + assert_eq!(size_mark(&h.app, "4K UHD", Some((3840, 2160))).as_deref(), Some("4K UHD・「スタジオ」が必要")); + } + h.app.preferences.language = "en".into(); + h.frames(3); + + // GPU path tracing renders 4K and never names a CPU profile. + super::super::select_image_method(&mut h.app, ImageMethod::GpuRaytrace); + h.frames(3); + h.step(&json!({"click":{"handle":"render:shots:size"}})).unwrap(); + h.step(&json!({"widget":"4K UHD · 3840 × 2160"})).unwrap(); + assert_eq!(size_mark(&h.app, "4K UHD", Some((3840, 2160))), None); + // 15360 × 4320 is past the GPU limits, yet Workstation would admit it: still no CPU profile. + assert_eq!(size_mark(&h.app, "16K", Some((15360, 4320))), None); + assert!(!size_limit_reason(&h.app, 15360, 4320).contains("CPU resource profile")); + cpu::validate_progressive_image(&h.app).unwrap(); +} + #[test] fn shots_strip_render_submits_the_current_job_request() { let mut h = fixture(); diff --git a/crates/ui-egui/src/render_workspace_shots.rs b/crates/ui-egui/src/render_workspace_shots.rs index 495f619ba9..ef4879d8a1 100644 --- a/crates/ui-egui/src/render_workspace_shots.rs +++ b/crates/ui-egui/src/render_workspace_shots.rs @@ -59,9 +59,14 @@ pub(crate) fn contents(app: &mut SolveApp, ui: &mut egui::Ui) { start = response.clicked(); let response = ui_kit::compact_dropdown_menu(ui, "render_shots_size", &size_label, |ui| { for (label, width, height) in SIZE_PRESETS { - if ui_kit::list_picker(ui, label, &format!("{label} · {width} × {height}"), draft.width == width && draft.height == height) - .clicked() - { + let text = format!("{label} · {width} × {height}"); + let mark = super::inspector::size_mark(app, &text, Some((u64::from(width), u64::from(height)))); + let mut response = + ui_kit::list_picker(ui, label, mark.as_deref().unwrap_or(&text), draft.width == width && draft.height == height); + if mark.is_some() { + response = response.on_hover_text(super::inspector::size_limit_reason(app, width, height)); + } + if response.clicked() { size = Some((width, height)); ui.close(); } diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index f5b05014d7..8108bab2b4 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -4431,6 +4431,35 @@ fn render_workspace_pick_stage_tools_filter_sample_clicks_with_shots_shown_and_h assert!(h.app.tool.is_none() && h.app.dialog.is_none()); } +#[test] +fn modelling_select_panel_shows_and_clears_renders_component_pick() { + use egui::accesskit::{Role, Toggled}; + const BODY: &str = "Select bodies (a face picks its body)"; + const COMPONENT: &str = "Select components (a face picks its component)"; + let (mut h, _) = render_pick_sample(); + h.step(&json!({"click":{"handle":"render:pick:component"}})).unwrap(); + enter(&mut h.app, false).unwrap(); + h.settle(); + h.step(&json!({"click":{"handle":"tab:VIEW"}})).unwrap(); + h.step(&json!({"click":{"handle":"panel:VIEW:SELECT"}})).unwrap(); + + let check = |h: &Harness, body: bool, component: bool| { + assert_eq!((h.app.ui.pick_bodies, h.app.ui.pick_components), (body, component)); + let tree = h.output.accesskit_update.as_ref().unwrap(); + for (label, on) in [(BODY, body), (COMPONENT, component)] { + let node = tree.nodes.iter().find(|(_, n)| n.role() == Role::CheckBox && n.label() == Some(label)); + assert_eq!(node.and_then(|(_, n)| n.toggled()), Some(if on { Toggled::True } else { Toggled::False }), "{label}"); + } + }; + check(&h, false, true); + h.step(&json!({"widget":"Faces"})).unwrap(); + for (label, body, component) in [(COMPONENT, false, false), (BODY, true, false), (COMPONENT, false, true), (BODY, true, false)] { + h.step(&json!({"widget":label})).unwrap(); + check(&h, body, component); + assert!(h.app.ui.pick_off.iter().any(|kind| kind == "faces"), "changing pick mode must preserve the panel's Faces filter"); + } +} + #[test] fn render_workspace_click_modifiers_clear_and_browser_share_selection() { use solidcraft_engine::Sel; @@ -6407,3 +6436,85 @@ fn the_field_of_view_rescales_only_the_design_view() { assert!(h.app.ui.perspective); assert!((size(h.app.cam, 1.2) / size(design, 0.6) - 1.0).abs() < 1e-9, "the design view keeps its size: {:?}", h.app.cam); } + +/// Fit Product keeps the whole product inside the image, whatever its aspect: a picture +/// taller than wide moves the camera back so the sides hold it too. +#[test] +fn fit_aspect_keeps_the_product_inside_a_tall_image() { + use solidcraft_engine::geom::{Aabb3, Vec3}; + let bounds = Aabb3 { min: Vec3::new(-30.0, -30.0, -30.0), max: Vec3::new(30.0, 30.0, 30.0) }; + let radius = bounds.diagonal(); + for fov in [0.0, std::f64::consts::FRAC_PI_4] { + let mut landscape = Camera { fov, ..Default::default() }; + assert!(landscape.fit_aspect(&bounds, 2.0)); + let mut plain = Camera { fov, ..Default::default() }; + plain.fit(&bounds); + assert_eq!(landscape, plain, "a wide picture keeps the standard fit"); + for aspect in [0.5, 0.25] { + let mut cam = Camera { fov, ..Default::default() }; + assert!(cam.fit_aspect(&bounds, aspect)); + for p in corners(&bounds) { + let q = cam.view_proj(aspect, radius).apply(p); + let (x, y) = (q[0] / q[3], q[1] / q[3]); + assert!(x.abs() <= 1.0 && y.abs() <= 1.0, "fov {fov}, aspect {aspect}: corner {p:?} projects to ({x}, {y}), outside the picture"); + } + } + // Nothing to fit leaves the camera alone. + let mut cam = Camera { fov, distance: 123.0, ..Default::default() }; + assert!(!cam.fit_aspect(&Aabb3::EMPTY, 0.5)); + assert_eq!(cam.distance, 123.0); + } +} + +fn corners(b: &solidcraft_engine::geom::Aabb3) -> Vec { + let mut out = Vec::new(); + for x in [b.min.x, b.max.x] { + for y in [b.min.y, b.max.y] { + for z in [b.min.z, b.max.z] { + out.push(solidcraft_engine::geom::Vec3::new(x, y, z)); + } + } + } + out +} + +/// Entering Render fits the product to the setup in effect, not to the draft that setup +/// replaces: in a cold workspace whose saved Studio setup is portrait (1024 × 4096) the fit +/// waits for the setup to load, so with Frame open the product sits inside the final 1:4 +/// picture, in orthographic and in perspective, and leaving Render gives the Design camera +/// back. +#[test] +fn entering_render_fits_a_saved_portrait_setup_inside_its_final_frame() { + for fov in [0.0, 0.8] { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":60,"width":20,"height":20})).unwrap(); + let name = h.app.render_workspace.view.name.clone(); + h.app.run("render.settings.set", json!({"name":name,"set":{"width":1024,"height":4096}})).unwrap(); + h.app.ui.perspective = fov > 0.0; + h.app.cam.fov = fov; + h.frames(2); + let design = (h.app.cam, h.app.ui.perspective); + let draft = &h.app.render_workspace.view.draft; + assert_ne!((draft.width, draft.height), (1024, 4096), "the cold draft is still the default"); + h.app.render_workspace.view.stage = Stage::Frame; + enter(&mut h.app, true).unwrap(); + h.frames(3); + assert!(shot::overlay_visible(&h.app)); + let draft = &h.app.render_workspace.view.draft; + assert_eq!((draft.width, draft.height), (1024, 4096), "the saved setup is in effect"); + let frame = shot::preview_rect(&h.app, h.app.viewport.rect.unwrap()).unwrap(); + assert!((frame.width() / frame.height() - 0.25).abs() < 1e-3, "{frame:?}"); + let scene = solidcraft_engine::product_scene::extract(&h.app.session).unwrap(); + let cam = h.app.cam; + let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(cam.target); + for p in corners(&scene.bounds) { + let (x, y, _) = cam.to_screen(p, f64::from(frame.width()), f64::from(frame.height()), radius).unwrap(); + assert!( + (0.0..=f64::from(frame.width())).contains(&x) && (0.0..=f64::from(frame.height())).contains(&y), + "fov {fov}: corner {p:?} at ({x}, {y}) outside the {frame:?} picture" + ); + } + enter(&mut h.app, false).unwrap(); + assert_eq!((h.app.cam, h.app.ui.perspective), design, "fov {fov}: leaving Render restores the Design camera"); + } +} diff --git a/crates/ui-egui/src/scale_tests.rs b/crates/ui-egui/src/scale_tests.rs index b0c28a4ba9..c4afcbc250 100644 --- a/crates/ui-egui/src/scale_tests.rs +++ b/crates/ui-egui/src/scale_tests.rs @@ -283,7 +283,7 @@ pub(crate) fn view_frame( clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: crate::gpu::analysis_colors(&t.analysis), access: None, diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 16f9897a96..ada0605f4e 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -355,6 +355,34 @@ impl Harness { }) } + /// Accessibility bounds include scrolled-out page content. Reveal that content with + /// wheel input before using its coordinates; window actions and navigation stay put. + fn reveal_widget(&mut self, text: &str, near: Option<&str>) -> Result<[f64; 2], String> { + let find = |h: &Self| match near { + Some(label) => h.widget_near(text, label), + None => h.widget(text), + }; + for _ in 0..32 { + let point = find(self).ok_or_else(|| format!("no widget `{text}` on screen"))?; + let at = egui::pos2(point[0] as f32, point[1] as f32); + let page = self.app.prefs_window.open.then(|| rect_of("prefs:page_body")).flatten(); + let chrome = ["prefs:ok", "prefs:apply", "prefs:cancel", "prefs:search"].iter().any(|key| handle_at(key) == Some(at)); + let Some(page) = page.filter(|page| !chrome && page.x_range().contains(at.x)) else { return Ok(point) }; + if page.contains(at) { + return Ok(point); + } + self.call("ui.scroll", json!({"x":page.center().x,"y":page.center().y,"delta":if at.y < page.top() {100} else {-100}})); + // Follow the wheel's actual smoothing, rather than clicking its moving content. + for _ in 0..120 { + if self.ctx.input(|input| !input.is_scrolling()) { + break; + } + self.frame(); + } + } + Err(format!("widget `{text}` could not be revealed in its page")) + } + /// Centres of the visible widgets whose label or value is `text`, last drawn first. fn widgets(&self, text: &str) -> Vec<[f64; 2]> { let Some(tree) = self.output.accesskit_update.as_ref() else { return Vec::new() }; @@ -457,12 +485,10 @@ impl Harness { self.call("ui.click", p) } else if let Some(text) = s.get("widget").and_then(Value::as_str) { // A dialog field, choice or button, found by its text. - let found = match s.get("near").and_then(Value::as_str) { - Some(near) => self.widget_near(text, near), - None => self.widget(text), - }; - let [x, y] = found.ok_or_else(|| format!("no widget `{text}` on screen"))?; + let near = s.get("near").and_then(Value::as_str); + let [x, y] = self.reveal_widget(text, near)?; self.call("ui.move", json!({"x": x, "y": y})); + let [x, y] = self.reveal_widget(text, near)?; self.call("ui.click", json!({"x": x, "y": y})) } else if let Some(at) = s.get("hold") { // Press and hold still (Select Other): `frames` with the button down (default 40, diff --git a/crates/ui-egui/src/section_browser_tests.rs b/crates/ui-egui/src/section_browser_tests.rs index c060bf11f9..f2008b6d18 100644 --- a/crates/ui-egui/src/section_browser_tests.rs +++ b/crates/ui-egui/src/section_browser_tests.rs @@ -105,6 +105,7 @@ fn ok(h: &mut Harness) { #[test] fn edit_starts_from_the_section_as_it_was_left() { let mut h = harness(); + h.app.preferences.viewport_section_status = false; h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); h.app.session.selection = vec![solidcraft_engine::Sel::Plane { name: "XY".into() }]; h.app.start("inspect.section"); @@ -179,6 +180,7 @@ fn the_hatch_option_carries_through_the_section() { #[test] fn ok_with_no_plane_picked_turns_the_section_off() { let mut h = harness(); + h.app.preferences.viewport_section_status = false; h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); h.app.run("inspect.section", json!({"plane": "XZ", "offset": 10})).unwrap(); h.app.session.selection.clear(); @@ -202,3 +204,62 @@ fn ok_with_no_plane_picked_turns_the_section_off() { h.frames(3); assert_eq!(h.app.dialog.as_ref().and_then(|d| d.error.as_deref()), Some("select a plane or planar face first")); } + +#[test] +fn editing_programmatic_sections_keeps_actual_plane_flip_hatch_and_cancel_then_moves_it() { + for params in [ + json!({"plane":"XY","at":-25.4,"flip":true,"hatch":false}), + json!({"plane":{"origin":[7,-12,4],"normal":[-3,-4,0]},"offset":5,"hatch":false}), + json!({"plane":{"origin":[7,-12,4],"normal":[0,0,-1]},"offset":-5,"flip":true}), + ] { + let mut h = harness(); + h.app.run("inspect.section", params).unwrap(); + assert!(h.app.dialog.is_none(), "programmatic commands never open a form"); + let cut = h.app.session.section.unwrap(); + let hatch = h.app.session.section_hatch; + crate::dialogs::edit_section(&mut h.app); + let d = h.app.dialog.as_ref().unwrap(); + let cmds = crate::dialogs::apply_commands(&h.app, d).unwrap(); + let mut scratch = h.app.session.scratch(); + scratch.execute(&cmds[0].0, &cmds[0].1).unwrap(); + let preview = scratch.section.unwrap(); + assert!((preview.0 - cut.0).len() < 1e-9 && (preview.1 - cut.1).len() < 1e-9); + assert_eq!(scratch.section_hatch, hatch); + assert_eq!(h.app.session.section, Some(cut), "edit intent cannot toggle the active cut off"); + crate::dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.section, Some(cut)); + crate::dialogs::edit_section(&mut h.app); + if let Some(crate::dialogs::Kind::Section { offset, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *offset = "30 mm".into(); + } + let cmds = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap(); + assert_ne!(h.app.session.section.unwrap().0, cut.0); + assert!( + (h.app.session.section.unwrap().1 - cut.1).len() < 1e-12, + "moving the plane retains its clipping direction within normalisation precision" + ); + } +} + +#[test] +fn section_ribbon_toggle_and_setting_edit_have_separate_intent_and_compatibility() { + let mut h = harness(); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"YZ","offset":3})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().into_iter().find(|e| e.command == "inspect.section").unwrap(); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:setting",item.id)}})).unwrap(); + assert!(h.app.dialog.is_some() && h.app.session.section.is_some()); + crate::dialogs::cancel(&mut h.app); + h.app.start("inspect.section"); + assert!(h.app.dialog.is_none() && h.app.session.section.is_none()); + assert!(h.app.session.analysis.is_some()); + h.app.run("inspect.section", json!({"plane":"XY"})).unwrap(); + h.app.preferences.viewport_section_status = false; + let cut = h.app.session.section; + h.app.start("inspect.section"); + assert!(h.app.dialog.is_some()); + assert_eq!(h.app.session.section, cut); +} diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs index 320c8bd6c3..59d9ea1ea2 100644 --- a/crates/ui-egui/src/select_other.rs +++ b/crates/ui-egui/src/select_other.rs @@ -4,6 +4,8 @@ //! //! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the //! viewport menu's "Select Other…", opens a list at the cursor of every pickable thing there, +//! including hidden geometry in a six-point radius without changing its visibility by default. +//! The `select_other_hidden` preference restores the visible-only list. It is //! in the selection filter's and the open dialog's terms, front to back like layers: sketch //! points and curves first (drawn over the model), then what belongs to a body, one body at a //! time and the nearest body first (its vertices, edges, face, then the body itself), then @@ -35,6 +37,7 @@ use crate::viewport::{Hit, Proj}; pub const HOLD_SECS: f64 = 0.5; /// How far (screen points) a repeat click may be from the last one and still cycle. const CYCLE_SLOP: f32 = 4.0; +const ROW_HEIGHT: f32 = 24.0; /// The most rows kept per kind of thing (vertices, edges, faces, …). The list exists to tell a /// handful of overlapping things apart; nearest first, farther ones of the same kind are dropped /// so it stays readable and bounded. @@ -61,6 +64,7 @@ pub struct Popup { /// The model and dialog it was made for: it closes when they change. revision: u64, dialog: bool, + include_hidden: bool, /// The browser's body highlight is ours to clear. lit_body: bool, /// The keys held at the press or click that opened it. @@ -94,12 +98,12 @@ pub struct State { /// Every pickable thing under `pos`, front to back (the sketch being edited first): see /// [`order`]. pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { - rows_capped(app, proj, pos).0 + rows_capped(app, proj, pos, app.preferences.select_other_hidden).0 } /// [`rows`], with how many things were under the cursor in all. -fn rows_capped(app: &SolveApp, proj: &Proj, pos: Pos2) -> (Vec, usize) { - let hits = crate::viewport::pick_all(app, proj, pos); +fn rows_capped(app: &SolveApp, proj: &Proj, pos: Pos2, through: bool) -> (Vec, usize) { + let hits = if through { crate::viewport::pick_through(app, proj, pos) } else { crate::viewport::pick_all(app, proj, pos) }; let mut found = crate::viewport::candidates(app, &hits); let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks()); // A body behind each face (body picking already turns faces into bodies). @@ -387,14 +391,23 @@ pub fn open(app: &mut SolveApp, at: Pos2, held: Held) -> bool { } fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2, held: Held) -> bool { - let (rows, total) = rows_capped(app, proj, at); + let (rows, total) = rows_capped(app, proj, at, app.preferences.select_other_hidden); + close(app); if rows.is_empty() { app.set_status("Nothing to select here", false); return false; } - close(app); - app.viewport.select_other.popup = - Some(Popup { at, rows, total, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false, held }); + app.viewport.select_other.popup = Some(Popup { + at, + rows, + total, + hovered: None, + revision: app.session.revision, + dialog: app.dialog.is_some(), + include_hidden: app.preferences.select_other_hidden, + lit_body: false, + held, + }); true } @@ -457,8 +470,8 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Result { return Ok(describe(app)); } if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - if !(x.is_finite() && y.is_finite()) { - return Err("x and y must be numbers".into()); + if !(x.is_finite() && y.is_finite()) || x.abs() > 1e6 || y.abs() > 1e6 { + return Err("x and y must be finite screen points within ±1000000".into()); } if app.viewport.rect.is_none() { return Err("no viewport".into()); @@ -583,7 +596,7 @@ pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool { if c.at.distance(at) > CYCLE_SLOP || c.key != cycle_key(app) || c.selection != app.session.selection { return false; } - let rows = rows(app, proj, at); + let rows = rows_capped(app, proj, at, false).0; if rows.len() < 2 { return false; } @@ -602,24 +615,38 @@ pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool { /// Draw the open list; handle its hover, clicks, and a press elsewhere (which closes it). pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some(p) = app.viewport.select_other.popup.clone() else { return }; - if p.revision != app.session.revision || p.dialog != app.dialog.is_some() || app.tool.is_some() { + if p.revision != app.session.revision + || p.dialog != app.dialog.is_some() + || p.include_hidden != app.preferences.select_other_hidden + || app.tool.is_some() + { close(app); return; } let t = Tokens::get(); let mut hovered = None; let mut picked = None; - let w = crate::context_menu::LIST_W - 10.0; - // Title, rows up to the scroll height, margins and border. - let size = vec2(w + 10.0, 30.0 + (p.rows.len() as f32 * 24.0).min(360.0)); + let screen = ctx.content_rect(); + let margins = crate::ui_kit::Menu::frame().total_margin().sum(); + let width = crate::context_menu::LIST_W.min(screen.width()); + let w = (width - margins.x).max(0.0); + let header = 20.0; + let gap = vec2(12.0, 12.0); + let at = pos2(p.at.x.clamp(screen.left(), screen.right()), p.at.y.clamp(screen.top(), screen.bottom())); + let rows_height = p.rows.len() as f32 * ROW_HEIGHT; + let overhead = header + margins.y; + let scroll_height = (screen.height() - overhead).max(0.0); + let size = vec2(width, overhead + rows_height.min(scroll_height)); let resp = Container::new(ctx, Surface::Popup) .area(egui::Id::new("sc_select_other")) - .fixed_pos(crate::context_menu::place(p.at, vec2(12.0, 12.0), size, ctx.content_rect())) + .fixed_pos(crate::context_menu::place(at, gap, size, screen)) .constrain(true) .order(egui::Order::Foreground) .show(ctx, |ui| { - crate::context_menu::menu_frame(ui, |ui| { - let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); + // Area remembers the preceding list's size, which must not limit a longer list. + ui.set_max_height(size.y); + crate::ui_kit::Menu::show(ui, width, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(w, header), Sense::hover()); let title = crate::i18n::tr_label("Select Other").to_uppercase(); let title = if p.total > p.rows.len() { format!("{title} · {} / {}", p.rows.len(), p.total) } else { title }; ui.painter().text( @@ -629,9 +656,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { FontId::proportional(crate::theme::Tokens::get().font_panel), t.text_dim, ); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + egui::ScrollArea::vertical().min_scrolled_height(0.0).max_height(scroll_height).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; for (i, row) in p.rows.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW_HEIGHT), Sense::click()); crate::scenario::publish_handle(&format!("select_other:{i}"), r.center()); if resp.hovered() { hovered = Some(i); @@ -663,6 +691,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }); }); }); + crate::scenario::publish_rect("select_other", resp.response.rect); if let Some(pop) = app.viewport.select_other.popup.as_mut() && pop.hovered != hovered { diff --git a/crates/ui-egui/src/select_other_tests.rs b/crates/ui-egui/src/select_other_tests.rs index 00af395ab1..531e887420 100644 --- a/crates/ui-egui/src/select_other_tests.rs +++ b/crates/ui-egui/src/select_other_tests.rs @@ -659,3 +659,220 @@ fn a_curve_from_intersect_is_labelled_intersected_and_one_from_project_projected h.app.run("sketch.project", json!({"refs":[{"body":"Block"}]})).unwrap(); assert!(label(&h, LinkKind::Project).ends_with("(projected)")); } +#[test] +fn long_press_picks_hidden_profiles_behind_a_body_without_revealing_them() { + let mut h = Harness::new(); + h.app.set_origin_shown(false); + h.app.run("sketch.create", json!({"plane":"XY","name":"Buried profile"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let sketch = h.app.session.doc.find_feature("Buried profile").unwrap().id; + h.app.run("solid.box", json!({"length":20,"width":20,"height":10,"corner":[-5,-5,1],"body_name":"Cover"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.app.session.visibility.hidden_sketches.push(sketch); + h.app.start("solid.extrude"); + h.app.dialog.as_mut().unwrap().inputs[0].items.clear(); + h.settle(); + assert_eq!(h.call("ui.prefs", json!({}))["result"]["select_other_hidden"], true); + assert_eq!(h.call("ui.prefs", json!({"select_other_hidden":false}))["ok"], true); + h.step(&json!({"hold":{"world":[5,5,0]}})).unwrap(); + assert!(!h.app.viewport.select_other.popup.as_ref().unwrap().rows.iter().any(|r| matches!(r.sel, Sel::Profile { .. }))); + h.call("ui.selectOther", json!({"close":true})); + assert_eq!(h.call("ui.prefs", json!({"select_other_hidden":true}))["ok"], true); + let before = h.app.session.visibility.hidden_sketches.clone(); + h.step(&json!({"hold":{"world":[5,5,0]}})).unwrap(); + let popup = h.app.viewport.select_other.popup.as_ref().unwrap(); + let row = popup.rows.iter().position(|r| r.sel == Sel::Profile { sketch, index: 0 }).unwrap(); + h.call("ui.selectOther", json!({"pick":row})); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, [Sel::Profile { sketch, index: 0 }]); + assert_eq!(h.app.session.visibility.hidden_sketches, before); + assert!(!crate::viewport::visible_sketches(&h.app).contains(&sketch)); + assert!(h.app.session.selection.is_empty()); +} + +#[test] +fn see_through_includes_hidden_bodies_and_occluded_edges_but_normal_picks_do_not() { + let mut h = Harness::new(); + h.app.set_origin_shown(false); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Hidden"})).unwrap(); + h.app.run("solid.box", json!({"length":30,"width":30,"height":10,"corner":[-10,-10,20],"body_name":"Cover"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + let point = h.at(&json!({"world":[5,0,10]})).unwrap(); + let pos = egui::pos2(point[0] as f32, point[1] as f32); + let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()); + let edge = |hit: &crate::viewport::Hit| matches!(hit, crate::viewport::Hit::Edge { body, .. } if body == "Hidden"); + assert!(crate::viewport::pick_through(&h.app, &proj, pos).iter().any(edge)); + assert!(!crate::viewport::pick_all(&h.app, &proj, pos).iter().any(edge)); + h.app.session.visibility.hidden_bodies.push("Hidden".into()); + let hidden = h.app.session.visibility.hidden_bodies.clone(); + h.call("ui.prefs", json!({"select_other_hidden":false})); + h.call("ui.selectOther", json!({"x":pos.x,"y":pos.y})); + assert!(!h.app.viewport.select_other.popup.as_ref().unwrap().rows.iter().any(|r| r.sel == Sel::Body { name: "Hidden".into() })); + h.call("ui.selectOther", json!({"close":true})); + h.call("ui.prefs", json!({"select_other_hidden":true})); + h.call("ui.selectOther", json!({"x":pos.x,"y":pos.y})); + let i = h.app.viewport.select_other.popup.as_ref().unwrap().rows.iter().position(|r| r.sel == Sel::Body { name: "Hidden".into() }).unwrap(); + h.call("ui.selectOther", json!({"pick":i})); + assert_eq!(h.app.session.selection, [Sel::Body { name: "Hidden".into() }]); + assert_eq!(h.app.session.visibility.hidden_bodies, hidden); + assert!( + !crate::viewport::pick_all(&h.app, &proj, pos).iter().any(|hit| matches!(hit, crate::viewport::Hit::Face { body, .. } if body == "Hidden")) + ); +} + +#[test] +fn approximate_long_click_reaches_profiles_and_shows_all_eight_rows() { + let mut h = Harness::new(); + h.app.set_origin_shown(false); + let mut sketches = Vec::new(); + for i in 0..8 { + h.app.run("sketch.create", json!({"plane":"XY","name":format!("Layer{i}")})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + sketches.push(h.app.session.doc.find_feature(&format!("Layer{i}")).unwrap().id); + } + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.app.session.visibility.hidden_sketches = sketches.clone(); + h.app.start("solid.extrude"); + h.app.dialog.as_mut().unwrap().inputs[0].items.clear(); + h.settle(); + let at = h.at(&json!({"world":[10,5,0]})).unwrap(); + let pos = egui::pos2(at[0] as f32 + 3.0, at[1] as f32); + let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()); + assert!(crate::viewport::pick_all(&h.app, &proj, pos).iter().all(|x| !matches!(x, crate::viewport::Hit::Profile { .. }))); + h.step(&json!({"hold":[pos.x,pos.y]})).unwrap(); + assert_eq!(h.app.viewport.select_other.popup.as_ref().unwrap().rows.len(), 8); + h.frames(3); + if let Ok(dir) = std::env::var("SOLIDCRAFT_SELECT_OTHER_EVIDENCE") { + let ctx = crate::fonts::test_context(); + let mut textures = std::collections::HashMap::new(); + let mut last = None; + for _ in 0..4 { + let output = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }, + |ui| super::show(&mut h.app, ui.ctx()), + ); + crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta); + last = Some(output); + } + let output = last.unwrap(); + let rect = crate::scenario::rect_of("select_other").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + crate::ui_kit::test_raster(&ctx, output.shapes, output.pixels_per_point, rect.expand(4.0), &textures) + .save(std::path::Path::new(&dir).join("select-other.png")) + .unwrap(); + h.frames(4); + } + let rect = crate::scenario::rect_of("select_other").unwrap(); + assert!(rect.height() > 8.0 * super::ROW_HEIGHT, "all eight rows plus header: {rect:?}"); + for i in 0..8 { + assert!(rect.contains(crate::scenario::handle_at(&format!("select_other:{i}")).unwrap()), "row {i}: {rect:?}"); + } + h.step(&json!({"click":{"handle":"select_other:7"}})).unwrap(); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, [Sel::Profile { sketch: sketches[7], index: 0 }]); + h.app.session.visibility.hidden_sketches.clear(); + h.call("ui.prefs", json!({"select_other_hidden":false})); + assert!(!super::rows(&h.app, &proj, pos).iter().any(|r| matches!(r.sel, Sel::Profile { .. })), "old list uses the exact pointer"); + h.call("ui.prefs", json!({"select_other_hidden":true})); + assert_eq!(super::rows(&h.app, &proj, pos).iter().filter(|r| matches!(r.sel, Sel::Profile { .. })).count(), 8); + h.app.session.visibility.hidden_sketches = sketches.clone(); +} + +#[test] +fn see_through_control_rejects_out_of_range_coordinates() { + let mut h = Harness::new(); + h.settle(); + for x in [1e300, -1e300, 1e7] { + let reply = h.call("ui.selectOther", json!({"x":x,"y":10})); + assert_eq!(reply["ok"], false, "{reply}"); + } +} + +#[test] +fn popup_uses_window_height_and_flips_above_the_pointer() { + for (count, y, height) in [(20, 100.0, 1000), (20, 900.0, 1000), (20, 500.0, 1000), (60, 500.0, 1000), (20, 240.0, 480)] { + let (mut h, at) = two_lines(); + h.step(&json!({"hold":at})).unwrap(); + let popup = h.app.viewport.select_other.popup.as_mut().unwrap(); + popup.rows = vec![popup.rows[0].clone(); count]; + popup.total = count; + popup.at.y = y; + h.call("ui.resize", json!({"width":1600,"height":height})); + h.frames(4); + let rect = crate::scenario::rect_of("select_other").unwrap(); + assert!(h.ctx.content_rect().contains_rect(rect), "{count} rows: {rect:?}"); + if count == 20 && height == 1000 { + assert!(rect.height() > 360.0, "large window shows more than the old fixed cap: {rect:?}"); + assert!(rect.contains(crate::scenario::handle_at("select_other:19").unwrap()), "last row fits: {rect:?}"); + if y > 500.0 { + assert!(rect.bottom() <= y, "opens above the pointer: {rect:?}"); + } else if y < 500.0 { + assert!(rect.top() > y, "opens below the pointer: {rect:?}"); + } + } else { + assert!(!rect.contains(crate::scenario::handle_at(&format!("select_other:{}", count - 1)).unwrap()), "overflow needs scrolling"); + h.app.synthetic.push(egui::Event::PointerMoved(rect.center())); + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -2000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.frames(40); + assert!(rect.contains(crate::scenario::handle_at(&format!("select_other:{}", count - 1)).unwrap()), "can scroll to the last row"); + } + } +} + +#[test] +fn hidden_selection_preference_is_saved_and_older_settings_get_the_new_default() { + let prefs: crate::prefs::Prefs = serde_json::from_value(json!({})).unwrap(); + assert_eq!(serde_json::to_value(&prefs).unwrap()["select_other_hidden"], true); + let prefs: crate::prefs::Prefs = serde_json::from_value(json!({"select_other_hidden":false})).unwrap(); + assert_eq!(serde_json::to_value(&prefs).unwrap()["select_other_hidden"], false); +} + +#[test] +fn hidden_selection_preference_can_be_changed_and_cancelled_in_preferences() { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"open":"Sketch & selection"})); + crate::prefs::reveal_preference(&mut h, "prefs:select_other_hidden"); + h.step(&json!({"click":{"handle":"prefs:select_other_hidden"}})).unwrap(); + assert_eq!(h.call("ui.prefs", json!({}))["result"]["select_other_hidden"], false); + h.call("ui.prefs", json!({"button":"cancel"})); + assert_eq!(h.call("ui.prefs", json!({}))["result"]["select_other_hidden"], true); +} + +#[test] +fn changing_hidden_selection_or_opening_an_empty_point_clears_the_old_list() { + let mut h = Harness::new(); + h.app.set_origin_shown(false); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Hidden"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + let at = h.at(&json!({"world":[5,5,10]})).unwrap(); + let point = json!({"x":at[0],"y":at[1]}); + h.app.session.selection.clear(); + h.app.session.visibility.hidden_bodies.push("Hidden".into()); + h.call("ui.selectOther", point.clone()); + assert!(super::is_open(&h.app)); + h.call("ui.prefs", json!({"select_other_hidden":false})); + assert!(!super::is_open(&h.app), "old hidden rows close when the preference changes"); + assert_eq!(h.call("ui.selectOther", point.clone())["result"]["open"], false); + assert_eq!(h.call("ui.selectOther", json!({"pick":0}))["ok"], false); + assert!(h.app.session.selection.is_empty()); + h.call("ui.prefs", json!({"select_other_hidden":true})); + h.call("ui.selectOther", point.clone()); + h.app.preferences.select_other_hidden = false; + h.frames(2); + assert!(!super::is_open(&h.app), "direct preference changes also close stale rows"); + h.app.session.visibility.hidden_bodies.clear(); + h.call("ui.selectOther", point); + assert!(super::is_open(&h.app)); + let rect = h.app.viewport.rect.unwrap(); + assert_eq!(h.call("ui.selectOther", json!({"x":rect.left()+20.0,"y":rect.top()+20.0}))["result"]["open"], false); + assert!(!super::is_open(&h.app), "an empty point cannot keep the preceding list"); +} diff --git a/crates/ui-egui/src/shell.rs b/crates/ui-egui/src/shell.rs index 5303e6cf3e..1cdf328cd3 100644 --- a/crates/ui-egui/src/shell.rs +++ b/crates/ui-egui/src/shell.rs @@ -132,7 +132,7 @@ pub(crate) fn register_panel(app: &mut SolveApp, id: &'static str, rect: Option< /// Shared workspace-card bounds below the ribbon and above a floating bottom strip. pub(crate) fn panel_bounds(app: &SolveApp, mut available: Rect) -> Rect { for card in &app.shell.cards { - available.min.y = card.bottom().clamp(available.top(), available.bottom()); + available.min.y = within(card.bottom(), available.top(), available.bottom()); } if crate::render_workspace::active(app) { let bottom = app.shell.cards.iter().map(|r| r.bottom()).fold(available.top() - Tokens::get().space_8, f32::max); @@ -140,7 +140,7 @@ pub(crate) fn panel_bounds(app: &SolveApp, mut available: Rect) -> Rect { } for (rect, edge) in app.shell.workspace_panels.values() { if matches!(edge, Some(crate::ui_kit::Edge::Bottom)) { - available.max.y = rect.top().clamp(available.top(), available.bottom()); + available.max.y = within(rect.top(), available.top(), available.bottom()); } } available @@ -205,7 +205,17 @@ pub(crate) fn bottom_overlay_rect(app: &SolveApp, mut viewport: Rect) -> Rect { /// The former central panel remains the anchor for controls and docked panels. pub(crate) fn content_rect(app: &SolveApp, viewport: Rect) -> Rect { - app.shell.content.unwrap_or(viewport).intersect(viewport) + // The content kept from the last frame can lie outside a viewport that has shrunk or moved + // since (a resize, a new scale): the intersection is then turned inside out, its min past + // its max. The viewport stands in for it. + let content = app.shell.content.unwrap_or(viewport).intersect(viewport); + if content.is_positive() { content } else { viewport } +} + +/// `v` kept within `lo..=hi`, or `lo` when that range is empty: `f32::clamp` panics on +/// `lo > hi`, which a rect squeezed to nothing between cards gives. +pub(crate) fn within(v: f32, lo: f32, hi: f32) -> f32 { + if lo <= hi { v.clamp(lo, hi) } else { lo } } /// Free anchor space shared by both layouts. Compact edge hosts have already consumed @@ -218,10 +228,10 @@ pub(crate) fn workspace_content(app: &SolveApp, mut content: Rect) -> Rect { let gap = Container::floating_card(Surface::Panel).gap(); for (rect, edge) in app.shell.workspace_panels.values() { match edge { - Some(crate::ui_kit::Edge::Left) => content.min.x = (rect.right() + gap).clamp(content.left(), content.right()), - Some(crate::ui_kit::Edge::Right) => content.max.x = (rect.left() - gap).clamp(content.left(), content.right()), - Some(crate::ui_kit::Edge::Bottom) => content.max.y = (rect.top() - gap).clamp(content.top(), content.bottom()), - Some(crate::ui_kit::Edge::Top) => content.min.y = (rect.bottom() + gap).clamp(content.top(), content.bottom()), + Some(crate::ui_kit::Edge::Left) => content.min.x = within(rect.right() + gap, content.left(), content.right()), + Some(crate::ui_kit::Edge::Right) => content.max.x = within(rect.left() - gap, content.left(), content.right()), + Some(crate::ui_kit::Edge::Bottom) => content.max.y = within(rect.top() - gap, content.top(), content.bottom()), + Some(crate::ui_kit::Edge::Top) => content.min.y = within(rect.bottom() + gap, content.top(), content.bottom()), None => {} } } @@ -230,16 +240,16 @@ pub(crate) fn workspace_content(app: &SolveApp, mut content: Rect) -> Rect { let gap = Container::for_layout(app.ui.layout, Surface::Panel).gap(); if let Some(history) = app.shell.history { if app.preferences.timeline_left { - content.min.x = (history.right() + gap).clamp(content.left(), content.right()); + content.min.x = within(history.right() + gap, content.left(), content.right()); } else { - content.max.y = (history.top() - gap).clamp(content.top(), content.bottom()); + content.max.y = within(history.top() - gap, content.top(), content.bottom()); } } if let Some(browser) = app.shell.browser { - content.min.x = (browser.right() + gap).clamp(content.left(), content.right()); + content.min.x = within(browser.right() + gap, content.left(), content.right()); } if let Some(column) = app.shell.dialog_column { - content.min.x = (column.right() + gap).clamp(content.left(), content.right()); + content.min.x = within(column.right() + gap, content.left(), content.right()); } content } @@ -250,7 +260,7 @@ pub(crate) fn tool_drag_rect(app: &SolveApp, placement: Rect) -> Rect { let gap = Container::for_layout(app.ui.layout, Surface::Feature).gap(); let mut bounds = crate::agent_chat::tool_space(app, app.viewport.rect.unwrap_or(placement)); for card in app.shell.cards.iter().filter(|r| r.is_finite() && r.is_positive()) { - bounds.min.y = (card.bottom() + gap).clamp(bounds.top(), bounds.bottom()); + bounds.min.y = within(card.bottom() + gap, bounds.top(), bounds.bottom()); } bounds } diff --git a/crates/ui-egui/src/shell_tests.rs b/crates/ui-egui/src/shell_tests.rs index f1ea980a26..d78e478e0a 100644 --- a/crates/ui-egui/src/shell_tests.rs +++ b/crates/ui-egui/src/shell_tests.rs @@ -1190,3 +1190,59 @@ fn status_bar_stays_out_of_the_embed() { assert!(!crate::status_bar::shown(&h.app)); assert!(rect_of("status:bar").is_none() && h.app.shell.status_bar.is_none()); } + +/// Content kept from an earlier frame that lies outside the viewport now (the window shrank, the +/// scale grew) gave an inside-out content rect, min past max, and the Render workspace's frame +/// overlay panicked clamping into it: `min > max, or either was NaN. min = 1004.0, max = 937.8125`. +/// The viewport stands in for such content, and the space left beside cards never panics. +#[test] +fn content_outside_a_shrunk_viewport_gives_way_to_the_viewport() { + let mut h = Harness::new(); + let viewport = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + h.app.shell.content = Some(Rect::from_min_max(pos2(900.0, 650.0), pos2(1600.0, 1000.0))); + assert_eq!(content_rect(&h.app, viewport), viewport); + // A rect already turned inside out, with History and Browser cards at its edges. + let inside_out = Rect::from_min_max(pos2(500.0, 1004.0), pos2(300.0, 938.0)); + h.app.shell.history = Some(Rect::from_min_max(pos2(0.0, 900.0), pos2(800.0, 1000.0))); + h.app.shell.browser = Some(Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 800.0))); + for left in [false, true] { + h.app.preferences.timeline_left = left; + let free = workspace_content(&h.app, inside_out); + assert!(free.min.x.is_finite() && free.min.y.is_finite(), "{free:?}"); + } +} + +/// The interface scale raised after a click at the top of a narrow window (found by a UI fuzzer +/// and shrunk to these three steps): the content kept from the frame before lay outside the new +/// viewport, and the next frame's layout panicked clamping into it (`min = 264.0, max = +/// 165.28125`). +#[test] +fn a_larger_interface_scale_in_a_narrow_window_keeps_drawing() { + let mut h = Harness::new(); + for step in [ + json!({"call": "ui.resize", "params": {"width": 330.5693343218654, "height": 1445.8773151935186}}), + json!({"click": [206.1875, 17.0]}), + json!({"call": "ui.prefs", "params": {"ui_scale": 2.0}}), + ] { + h.step(&step).unwrap(); + } + h.frames(3); + assert!(h.app.viewport.rect.unwrap().is_positive()); +} + +#[test] +fn docked_assistant_beside_an_inverted_view_area_does_not_panic() { + // A surface reconfigure can leave the view area inside out, its left past its right (#9492). + for side in ["right", "left"] { + let mut h = Harness::new(); + h.app.home.open = false; + h.call("ui.set", json!({"layout":"floating"})); + h.call("ui.agent_chat", json!({"open":true,"side":side,"width":380,"picker":false})); + h.frames(5); + assert!(h.app.agent_chat.panel_rect.is_some(), "{side}: docked Assistant"); + h.app.viewport.rect = Some(Rect::from_min_max(pos2(264.1875, 30.0), pos2(159.0, 20.0))); + let ctx = h.ctx.clone(); + crate::viewport::refresh_layout(&mut h.app, &ctx); + assert!(h.app.viewport.layout.is_some(), "{side}: a layout for the next frame"); + } +} diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs index c966b3bd28..ea27233774 100644 --- a/crates/ui-egui/src/sketch3d.rs +++ b/crates/ui-egui/src/sketch3d.rs @@ -260,7 +260,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) - ); } painter.text( - s + vec2(10.0, 10.0), + crate::tool_cursor::readout_at(painter, proj.rect, s), egui::Align2::LEFT_TOP, format!("{}, {}, {}", crate::prefs::show_mm(app, h.x, 1), crate::prefs::show_mm(app, h.y, 1), crate::prefs::show_mm(app, h.z, 1)), egui::FontId::proportional(crate::theme::Tokens::get().font_small), diff --git a/crates/ui-egui/src/sketch_construction.rs b/crates/ui-egui/src/sketch_construction.rs new file mode 100644 index 0000000000..3390175c79 --- /dev/null +++ b/crates/ui-egui/src/sketch_construction.rs @@ -0,0 +1,160 @@ +//! Interactive construction-line family: reference picks, shared analytic previews and commits. + +use egui::Stroke; +use serde_json::{Value, json}; +use solidcraft_engine::cmd::{construction_line_choice, construction_line_mode, construction_line_solutions}; +use solidcraft_engine::geom::Vec2; + +use crate::SolveApp; +use crate::tools::Tool; +use crate::viewport::{Hit, Proj}; + +fn references(mode: &str) -> usize { + match mode { + "tangent_two" | "bisector" => 2, + "parallel" | "perpendicular" | "tangent_external" | "tangent_at" => 1, + _ => 0, + } +} + +pub fn hint(id: &str) -> String { + let key = match construction_line_mode(id).unwrap_or("free") { + "free" => "ui.construction.free_hint", + "horizontal" | "vertical" => "ui.construction.point_hint", + "angle" => "ui.construction.angle_hint", + "parallel" | "perpendicular" => "ui.construction.reference_hint", + "tangent_external" => "ui.construction.external_hint", + "tangent_at" => "ui.construction.contact_hint", + "tangent_two" => "ui.construction.common_hint", + "bisector" => "ui.construction.bisector_hint", + _ => "ui.construction.point_hint", + }; + crate::i18n::tr(key).to_owned() +} + +fn arg(point: &(Vec2, Option), preview: bool) -> Value { + if !preview && let Some(id) = &point.1 { + return json!(id); + } + json!([point.0.x, point.0.y]) +} + +/// Parameters for the line under the pointer, also used for the final click. +fn params(app: &SolveApp, t: &Tool, pointer: &(Vec2, Option), preview: bool) -> Option { + let mode = construction_line_mode(t.cmd)?; + let refs = references(mode); + if t.picks.len() < refs { + return None; + } + let first = t.pts.first(); + let at = match mode { + "free" | "angle" => first?, + "tangent_external" => first.unwrap_or(pointer), + _ => pointer, + }; + let mut p = json!({"mode": mode, "at": arg(at, preview), "near": [pointer.0.x, pointer.0.y]}); + if let Some(id) = t.picks.first() { + p["curve"] = json!(id); + } + if let Some(id) = t.picks.get(1) { + p["curve2"] = json!(id); + } + if mode == "free" { + p["through"] = arg(pointer, preview); + } + if mode == "angle" { + let angle = crate::sketch_dims::locked_value(app, t, "Angle").unwrap_or_else(|| (pointer.0 - at.0).angle()); + p["angle"] = json!(angle.to_degrees()); + if !preview && let Some(expression) = crate::sketch_dims::locked_expression(t, "Angle") { + p["angle"] = json!(expression); + } + } + Some(p) +} + +pub fn on_click(app: &mut SolveApp, tool: &mut Tool, hits: &[Hit], at: Option<(Vec2, Option)>) { + let Some(mode) = construction_line_mode(tool.cmd) else { return }; + let refs = references(mode); + if tool.picks.len() < refs { + let active = app.session.active_sketch; + let picked = hits.iter().find_map(|h| match h { + Hit::SketchCurve { sketch, id, .. } if Some(*sketch) == active => Some(id.clone()), + _ => None, + }); + if let Some(id) = picked { + let state = app.session.world_state(); + let valid = active.and_then(|sid| state.sketch(sid)).is_some_and(|ss| { + ss.sketch.curve_index(&id).and_then(|i| ss.sketch.curves.get(i)).is_some_and(|c| { + use solidcraft_engine::sketch::CurveKind; + match mode { + "parallel" | "perpendicular" | "bisector" => matches!(c.kind, CurveKind::Line { .. }), + "tangent_external" | "tangent_two" => matches!(c.kind, CurveKind::Circle { .. } | CurveKind::Arc { .. }), + "tangent_at" => !matches!(c.kind, CurveKind::Line { .. } | CurveKind::Ellipse { .. }), + _ => false, + } + }) + }); + if valid && !tool.picks.contains(&id) { + tool.picks.push(id); + } + } + return; + } + if let Some(at) = at { + place_point(app, tool, at); + } +} + +/// Enter in the angle box follows the same path as a click. +pub fn place_point(app: &mut SolveApp, t: &mut Tool, at: (Vec2, Option)) { + let Some(mode) = construction_line_mode(t.cmd) else { return }; + if t.picks.len() < references(mode) { + return; + } + if let Err(error) = crate::sketch_dims::validate(app, t) { + app.set_status(error, true); + return; + } + let at = crate::sketch_dims::placed(app, t, &at); + if matches!(mode, "free" | "angle" | "tangent_external") && t.pts.is_empty() { + t.pts.push(at); + return; + } + let Some(p) = params(app, t, &at, false) else { return }; + if app.run(t.cmd, p).is_ok() { + t.pts.clear(); + // Retain references for repeated parallel lines/tangents. Right-click resets the picks. + crate::sketch_dims::reset(t); + } +} + +/// Shared geometry, drawn far enough either way to cover the visible sketch plane. +pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { + let Some(pointer) = &t.hover else { return }; + let Some(mode) = construction_line_mode(t.cmd) else { return }; + let Some(p) = params(app, t, pointer, true) else { return }; + let state = app.session.world_state(); + let Some(ss) = app.session.active_sketch.and_then(|sid| state.sketch(sid)) else { return }; + let Ok(lines) = construction_line_solutions(&ss.sketch, &p, mode) else { return }; + let chosen = construction_line_choice(&lines, &p).ok(); + let tokens = crate::theme::Tokens::get(); + let center = ss.plane.to_local(app.cam.target); + let extent = app.cam.half_height().max(1.0) * 20.0; + for (i, line) in lines.iter().enumerate() { + let origin = line.start + line.direction * (center - line.start).dot(line.direction); + let points = [ss.plane.to_world(origin - line.direction * extent), ss.plane.to_world(origin + line.direction * extent)]; + let selected = chosen == Some(i); + let stroke = + Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { tokens.preview_new } else { tokens.sketch_accent.gamma_multiply(0.35) }); + crate::tools::paint_sketch_path(&points, 3, painter, proj, stroke); + } + if let Some(first) = t.pts.first() + && let Some(p) = proj.to_screen(ss.plane.to_world(first.0)) + { + painter.circle_stroke(p, 4.0, Stroke::new(1.5, tokens.sketch_accent)); + } +} + +#[cfg(test)] +#[path = "sketch_construction_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/sketch_construction_tests.rs b/crates/ui-egui/src/sketch_construction_tests.rs new file mode 100644 index 0000000000..3ef812fda5 --- /dev/null +++ b/crates/ui-egui/src/sketch_construction_tests.rs @@ -0,0 +1,106 @@ +use crate::scenario::Harness; +use serde_json::json; +use solidcraft_engine::sketch::CurveKind; + +fn sketch() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]],"ids":["a"],"infer":false})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[0,20]],"ids":["b"],"infer":false})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[40,0],"radius":5,"id":"c"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[60,0],"radius":5,"id":"d"})).unwrap(); + h.settle(); + h +} + +fn steps(h: &mut Harness, id: &str, clicks: &[[f64; 2]]) { + h.step(&json!({"start":id})).unwrap(); + for q in clicks { + h.step(&json!({"click":{"sketch":q}})).unwrap(); + } +} + +#[test] +fn every_construction_variant_is_interactive_and_stays_infinite() { + let cases: &[(&str, &[[f64; 2]])] = &[ + ("sketch.construction_line", &[[5., 10.], [15., 15.]]), + ("sketch.construction_line.horizontal", &[[5., 10.]]), + ("sketch.construction_line.vertical", &[[5., 10.]]), + ("sketch.construction_line.angle", &[[5., 10.], [15., 20.]]), + ("sketch.construction_line.parallel", &[[10., 0.], [5., 10.]]), + ("sketch.construction_line.perpendicular", &[[10., 0.], [5., 10.]]), + ("sketch.construction_line.tangent_external", &[[40., 5.], [50., 10.], [50., 15.]]), + ("sketch.construction_line.tangent_at", &[[40., 5.], [45., 0.]]), + ("sketch.construction_line.tangent_two", &[[40., 5.], [60., 5.], [50., 8.]]), + ("sketch.construction_line.bisector", &[[10., 0.], [0., 10.], [10., 10.]]), + ]; + for (id, clicks) in cases { + let mut h = sketch(); + crate::sketch_palette::set_mode(&mut h.app, 2); + let sid = h.app.session.active_sketch.unwrap(); + let before = h.app.session.doc.sketch(sid).unwrap().curves.len(); + steps(&mut h, id, clicks); + let sk = h.app.session.doc.sketch(sid).unwrap(); + assert_eq!(sk.curves.len(), before + 1, "{id}: {:?}", h.app.tool); + let curve = sk.curves.last().unwrap(); + assert!(curve.infinite && curve.construction && !curve.centerline, "{id}: {curve:?}"); + assert!(matches!(curve.kind, CurveKind::Line { .. })); + } +} + +#[test] +fn world_axes_are_not_mistaken_for_sketch_axes() { + // The world Y axis is sketch X on YZ. Sending its name as local "y" would + // silently create a perpendicular reference, so only sketch curves are picked. + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"YZ"})).unwrap(); + for id in ["sketch.construction_line.parallel", "sketch.construction_line.perpendicular", "sketch.construction_line.bisector"] { + let mut tool = crate::sketch_tools::tool_for(id).unwrap(); + super::on_click(&mut h.app, &mut tool, &[crate::viewport::Hit::Axis { name: "Y".into() }], None); + assert!(tool.picks.is_empty(), "{id} must not reinterpret a world axis as a local axis"); + } +} + +#[test] +fn construction_angle_accepts_inline_value_and_undo_is_one_step() { + let mut h = sketch(); + steps(&mut h, "sketch.construction_line.angle", &[[5., 10.]]); + h.step(&json!({"move":{"sketch":[15,15]}})).unwrap(); + h.settle(); + let tool = h.app.tool.as_mut().unwrap(); + tool.dims = crate::sketch_dims::boxes_for(tool.cmd, 1); + let dim = tool.dims.first_mut().unwrap(); + dim.locked = true; + dim.value = "30 deg".into(); + let mut tool = h.app.tool.take().unwrap(); + super::place_point(&mut h.app, &mut tool, (solidcraft_engine::geom::Vec2::new(15., 15.), None)); + let sid = h.app.session.active_sketch.unwrap(); + let sk = h.app.session.doc.sketch(sid).unwrap(); + let curve = sk.curves.last().unwrap(); + let CurveKind::Line { a, b } = curve.kind else { panic!("line") }; + assert!(((sk.point(b).unwrap() - sk.point(a).unwrap()).angle().to_degrees() - 30.).abs() < 1e-6); + let count = sk.curves.len(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.sketch(sid).unwrap().curves.len(), count - 1); +} + +#[test] +fn construction_family_menu_and_preference() { + for enabled in [true, false] { + let mut h = sketch(); + h.app.preferences.sketch_construction_lines = enabled; + h.settle(); + h.step(&json!({"click":{"handle":"panel:SKETCH:CREATE"}})).unwrap(); + h.frames(4); + assert_eq!(crate::scenario::handle_at("create_family:Construction Line").is_some(), enabled); + if enabled { + h.step(&json!({"click":{"handle":"create_family:Construction Line"}})).unwrap(); + h.frames(4); + let family = crate::sketch_families::family("sketch.construction_line").unwrap(); + for id in family.commands { + assert!(crate::scenario::handle_at(&format!("dropdown:{id}")).is_some(), "{id}"); + } + } + } +} diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 3626d50efc..df13d3f97c 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -39,6 +39,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec { use ValueKind::*; let b = DimBox::new; match (cmd, n) { + ("sketch.construction_line.angle", 1) => vec![b("Angle", Angle)], ("sketch.line" | "sketch.line.midpoint", n) if n >= 1 => vec![b("Length", Length), b("Angle", Angle)], ("sketch.rectangle.two_point" | "sketch.rectangle.center", 1) => vec![b("Width", Length), b("Height", Length)], ("sketch.rectangle.rounded", 1) => vec![b("Width", Length), b("Height", Length), b("Radius", Length)], @@ -72,6 +73,9 @@ fn eval(doc: &Document, text: &str, kind: ValueKind) -> solidcraft_engine::doc:: /// Whether a box becomes one of the shape's dimensions. A rounded rectangle's radius is its own /// parameter (sent with the shape); sides and rho are counts and shape factors. fn is_dimension(t: &Tool, label: &str) -> bool { + if solidcraft_engine::cmd::construction_line_mode(t.cmd).is_some() { + return t.cmd == "sketch.construction_line.angle" && label == "Angle"; + } !matches!(label, "Sides" | "Rho") && !(t.cmd == "sketch.rectangle.rounded" && label == "Radius") } @@ -88,6 +92,7 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { let Some(a) = t.pts.last().map(|p| p.0) else { return h }; let lk = |l: &str| locked(app, t, l); match (t.cmd, t.pts.len()) { + ("sketch.construction_line.angle", 1) => a + Vec2::from_angle(lk("Angle").unwrap_or_else(|| (h - a).angle())) * (h - a).len().max(1.0), ("sketch.line", _) | ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => { let v = h - a; let len = lk("Length").unwrap_or(v.len()); @@ -243,6 +248,7 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { let first = t.pts.first().map(|p| p.0).unwrap_or(a); let off = 20.0 * px; match (t.cmd, t.pts.len()) { + ("sketch.construction_line.angle", 1) => vec![((e - a).angle(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)], ("sketch.line", _) | ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => line_boxes(a, e, px, off), // The midpoint line runs from the end's mirror image through the midpoint to the end, // so its length is the whole line: twice the distance from the midpoint to the cursor. diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index a20556ed51..84ce24b9a3 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -361,6 +361,56 @@ fn place_from_the_origin_fully_constrains_new_shapes() { assert_eq!(draw(false, true), (0, 4), "placing needs Dimension new shapes"); } +/// A line ended on a spline, between its fit points (#9321): the cursor snaps On the spline as it +/// does on a line or a circle, and the click ties the end to the spline. +#[test] +fn a_line_end_on_a_spline_snaps_on_it_and_stays_on_it() { + use solidcraft_engine::sketch::{ConstraintKind, CurveKind}; + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.spline.fit_point", json!({"points": [[-30, 0], [-10, 18], [10, 6], [30, 20]]})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + let (spline, on) = { + let st = h.app.session.world_state(); + let sk = &st.sketch(sid).unwrap().sketch; + let ci = sk.curves.iter().position(|c| matches!(c.kind, CurveKind::Spline { .. })).unwrap(); + let poly = sk.polyline(ci); + // Partway along the spline, away from its fit points. + let w = poly.len() * 3 / 8; + (ci, (poly[w] + poly[w + 1]) * 0.5) + }; + h.step(&json!({"start": "sketch.line"})).unwrap(); + h.step(&json!({"click": {"world": [0, -25, 0]}})).unwrap(); + h.step(&json!({"move": {"world": [on.x, on.y, 0]}})).unwrap(); + let hover = h.app.tool.as_ref().unwrap().hover.clone().unwrap(); + assert!(hover.1.as_deref().is_some_and(|s| s.starts_with("on:")), "{hover:?}"); + h.step(&json!({"click": {"world": [on.x, on.y, 0]}})).unwrap(); + let st = h.app.session.world_state(); + let sk = &st.sketch(sid).unwrap().sketch; + let b = sk + .curves + .iter() + .find_map(|c| if let CurveKind::Line { b, .. } = c.kind { Some(b) } else { None }) + .unwrap_or_else(|| panic!("no line, {:?}", h.app.status)); + assert!( + sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::PointOnCurve { p, c } if p == b && c == spline)), + "the end is not tied to the spline: {:?}", + sk.constraints + ); + let end = sk.point(b).unwrap(); + let to_segment = |a: solidcraft_engine::geom::Vec2, b: solidcraft_engine::geom::Vec2| { + let ab = b - a; + let t = ((end - a).dot(ab) / ab.len2().max(1e-300)).clamp(0.0, 1.0); + (a + ab * t).dist(end) + }; + // The constraint puts the end on the spline itself; the polyline it is drawn with lies within + // a few µm of it between its samples. + let off = sk.polyline(spline).windows(2).map(|w| to_segment(w[0], w[1])).fold(f64::MAX, f64::min); + assert!(off < 0.02, "the end is {off} mm off the spline at {end:?}"); + assert!(end.dist(on) < 0.5, "the end moved from the cursor {on:?} to {end:?}"); +} + /// A line from a circle's centre to the circle, ending a little off the quadrant: the preview /// snaps to horizontal or vertical, so the click must place exactly that line, on the circle. #[test] diff --git a/crates/ui-egui/src/sketch_edit_tests.rs b/crates/ui-egui/src/sketch_edit_tests.rs index 882ee39ec0..77acff8bb5 100644 --- a/crates/ui-egui/src/sketch_edit_tests.rs +++ b/crates/ui-egui/src/sketch_edit_tests.rs @@ -412,3 +412,44 @@ fn a_sketch_opened_inside_a_component_gives_the_active_component_back() { app.finish_sketch(); assert_eq!(app.session.active_component, washer, "the component activated meanwhile stays active"); } + +/// "Turn to face a sketch when it opens": off, creating or editing a sketch leaves the view as it +/// is, and Look At still faces the sketch when asked. +mod open_view_preference { + use crate::scenario::Harness; + use serde_json::json; + + #[test] + fn turning_is_the_default() { + assert!(crate::prefs::Prefs::default().sketch_open_look_at); + } + + #[test] + fn off_a_new_sketch_keeps_the_view_and_look_at_still_turns() { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"sketch_open_look_at": false})); + assert!(!h.app.preferences.sketch_open_look_at, "set through the control channel"); + h.call("ui.view", json!({"view": "iso"})); + h.settle(); + let iso = h.app.cam; + h.step(&json!({"start": "sketch.create"})).unwrap(); + h.step(&json!({"click": {"plane": "XY"}})).unwrap(); + h.settle(); + assert!(h.app.session.active_sketch.is_some()); + assert_eq!(h.app.cam, iso, "the view stays where it was"); + crate::dialogs::look_at_sketch_again(&mut h.app); + h.settle(); + crate::scenario::check(&mut h, &json!({"sketching": true, "camera": {"yaw_deg": 0, "pitch_deg": 90}})).unwrap(); + } + + #[test] + fn off_editing_a_sketch_keeps_the_view() { + let mut app = crate::test_support::sample_app(); + app.preferences.sketch_open_look_at = false; + let id = super::first_sketch(&app); + let before = app.cam; + app.edit_sketch(id); + assert!(app.session.active_sketch.is_some()); + assert_eq!(app.settled_cam(), before); + } +} diff --git a/crates/ui-egui/src/sketch_families.rs b/crates/ui-egui/src/sketch_families.rs index ac3feeeef2..d89fcdc134 100644 --- a/crates/ui-egui/src/sketch_families.rs +++ b/crates/ui-egui/src/sketch_families.rs @@ -10,6 +10,21 @@ pub struct Family { } pub const FAMILIES: &[Family] = &[ + Family { + label: "Construction Line", + commands: &[ + "sketch.construction_line", + "sketch.construction_line.horizontal", + "sketch.construction_line.vertical", + "sketch.construction_line.angle", + "sketch.construction_line.parallel", + "sketch.construction_line.perpendicular", + "sketch.construction_line.tangent_external", + "sketch.construction_line.tangent_at", + "sketch.construction_line.tangent_two", + "sketch.construction_line.bisector", + ], + }, Family { label: "Line", commands: &["sketch.line", "sketch.line.midpoint"] }, Family { label: "Rectangle", diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 654ae788d3..aa0c0a93fa 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -36,7 +36,7 @@ use solidcraft_engine::sketch::PatternAlignment; use crate::SolveApp; use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind}; -use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput}; +use crate::selection::{AXES, CURVES, PROFILES, SKETCH_POINTS, SelInput}; use crate::tool_modifiers::{Audit, Scope, ToolModifiers}; use crate::ui_kit; use crate::viewport::{Hit, Proj}; @@ -185,21 +185,25 @@ impl Mod { fn inputs(k: &Mod) -> Vec { match k { - Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Base point", SKETCH_POINTS, false)], + Mod::Scale { .. } => { + vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Base point", SKETCH_POINTS, false)] + } Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)], // AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`). - Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Mirror line", AXES, false)], - Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)], - Mod::Transform { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true)], + Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Mirror line", AXES, false)], + Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Center", SKETCH_POINTS, false)], + Mod::Transform { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true)], // The direction is optional: `sketch.pattern.rectangular` documents sketch x as its // default, the way the engine reads it. Mod::PatternRect { .. } => vec![ - SelInput::new("Entities", CURVES | SKETCH_POINTS, true), + SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false), ], // The centre is required: `sketch.pattern.circular` has no default for it. - Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)], + 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. @@ -308,7 +312,7 @@ pub(crate) fn selected_equation(app: &SolveApp) -> Option { pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option { let active = session.active_sketch?; match hit { - Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } if *sketch != active => None, + Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } | Hit::Profile { sketch, .. } if *sketch != active => None, Hit::SketchCurve { id, .. } if matches!(d.kind, Kind::SketchModify(Mod::Corner { .. })) => { let state = session.world_state(); let sk = &state.sketch(active)?.sketch; @@ -395,6 +399,50 @@ pub fn double_click_pattern(app: &mut SolveApp, pos: Pos2) -> bool { id.is_some_and(|id| edit_pattern(app, &id)) } +/// Double-click a sketch curve to add its whole connected chain to the shared Entities input of +/// Mirror, Move/Copy, Scale and the two patterns, as a profile click adds a whole closed region +/// (#8390). The single-curve pick of the double-click's two clicks cancels itself out (each click +/// toggles the same curve), so the chain toggle lands on the state before the double-click. +pub fn double_click_entities(app: &mut SolveApp) -> bool { + let Some(Hit::SketchCurve { sketch, id, .. }) = app.viewport.hover.clone() else { + return false; + }; + if app.session.active_sketch != Some(sketch) { + return false; + } + let Some(dialog) = app.dialog.as_mut() else { + return false; + }; + // Only the commands whose first input is the shared Entities list take a chain here; Offset + // has its own Chain option, and the corner tools take one corner at a time. + let entities = matches!( + &dialog.kind, + Kind::SketchModify( + Mod::Scale { .. } | Mod::Mirror | Mod::Move { .. } | Mod::Transform { .. } | Mod::PatternRect { .. } | Mod::PatternCirc { .. } + ) + ); + if !entities || dialog.active != 0 { + return false; + } + let Ok(v) = app.session.execute("sketch.chain", &json!({ "curve": id })) else { + return false; + }; + let items: Vec = v["curves"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c.as_str()) + .map(|id| Sel::SketchCurve { id: id.to_string(), sketch: None }) + .collect(); + if items.is_empty() { + return false; + } + if let Some(input) = dialog.inputs.get_mut(0) { + input.toggle(items); + } + true +} + /// Place a circular pattern centre in empty sketch space without creating a document point. pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool { if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 { @@ -668,15 +716,26 @@ pub(crate) fn point_id<'a>(session: &solidcraft_engine::Session, sel: &'a Sel) - } } -/// The curves and lone points picked in input `i`, as the engine's `entities` list. +/// The curves and lone points picked in input `i`, as the engine's `entities` list. A profile +/// pick expands to every curve of its boundary and its holes (#8390). fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec { - items(inputs, i) - .iter() - .filter_map(|x| match x { - Sel::SketchCurve { id, .. } => Some(id.clone()), - _ => point_id(session, x).map(str::to_string), - }) - .collect() + let mut out = Vec::new(); + for x in items(inputs, i) { + match x { + Sel::SketchCurve { id, .. } => out.push(id.clone()), + Sel::Profile { sketch, index } => { + if let Some(ids) = session.world_state().sketch(*sketch).and_then(|ss| ss.profiles.get(*index)).map(|p| p.curve_ids()) { + out.extend(ids); + } + } + _ => { + if let Some(id) = point_id(session, x) { + out.push(id.to_string()); + } + } + } + } + out } /// Sketch curve ids picked in input `i`. @@ -889,9 +948,8 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map return Err("The mirror line must be a straight curve of this sketch or an origin axis in its plane".into()), None => return Err("Select the mirror line".into()), }; - if line.as_str().is_some_and(|line| entities.iter().any(|entity| entity == line)) { - return Err("The mirror line cannot also be mirrored".into()); - } + // The mirror line may itself be among the entities (a chain or profile the user + // picked in one action): the command leaves the line in place and mirrors the rest. ("sketch.mirror", json!({"entities": entities, "line": line})) } Mod::Move { x, y, angle, copy } => { diff --git a/crates/ui-egui/src/sketch_modify_ui_tests.rs b/crates/ui-egui/src/sketch_modify_ui_tests.rs index 03ae9b2f94..5f6be0563a 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -232,12 +232,12 @@ fn a_half_filled_window_is_an_error_instead_of_a_command() { origin_axis[0].items = vec![Sel::SketchCurve { id: ids[1].clone(), sketch: None }]; origin_axis[1].items = vec![Sel::Axis { name: "Z".into() }]; assert!(commands(&h.app, &Mod::Mirror, &origin_axis, &serde_json::Map::new()).is_err()); - // The line itself must not be in the entities: the old click flow could not express that - // either, and the engine silently dropped it. + // The mirror line may itself be among the entities (a chain or profile picked in one action): + // the command leaves the line in place and mirrors the rest. let mut line_in_both = mirror_inputs(); line_in_both[0].items = vec![Sel::SketchCurve { id: ids[0].clone(), sketch: None }]; line_in_both[1].items = vec![Sel::SketchCurve { id: ids[0].clone(), sketch: None }]; - assert!(commands(&h.app, &Mod::Mirror, &line_in_both, &serde_json::Map::new()).is_err()); + assert_eq!(commands(&h.app, &Mod::Mirror, &line_in_both, &serde_json::Map::new()).unwrap()[0].1, json!({"entities": [ids[0]], "line": ids[0]})); } #[test] diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index f7d7babccb..2596dee079 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -106,6 +106,9 @@ pub fn toggle_construction(app: &mut SolveApp) { /// Curves a drawing command just made take the drawing line type. pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) { + if solidcraft_engine::cmd::construction_line_mode(id).is_some() { + return; + } let m = mode(app); if m == 0 || !solidcraft_engine::find_command(id).is_some_and(|c| c.tab == "SKETCH" && c.panel == "CREATE") { return; diff --git a/crates/ui-egui/src/sketch_pattern_tree.rs b/crates/ui-egui/src/sketch_pattern_tree.rs index 5fd7767f33..8991f684f8 100644 --- a/crates/ui-egui/src/sketch_pattern_tree.rs +++ b/crates/ui-egui/src/sketch_pattern_tree.rs @@ -136,4 +136,36 @@ mod tests { h.frames(3); assert!(crate::scenario::handle_at(&handle).is_some()); } + + /// Where a text starts, drawn inside `within`. + fn text_left(h: &Harness, text: &str, within: egui::Rect) -> f32 { + let mut at = None; + for clipped in &h.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |s| { + if let egui::Shape::Text(t) = s + && t.galley.text() == text + && within.contains(t.pos + t.galley.rect.center().to_vec2()) + { + at = Some(t.pos.x + t.galley.rect.left()); + } + }); + } + at.unwrap_or_else(|| panic!("{text} in {within:?}")) + } + + /// #8718: in the vertical History a sketch's pattern rows step in from the sketch's name as far + /// as they do in the browser, with the eye at the rows' end. + #[test] + fn left_history_patterns_step_in_from_their_sketch_like_the_browser() { + let (mut h, sketch, rectangular, _) = harness(true); + let label = format!("{} {}", crate::i18n::tr_label("Rectangular Pattern"), rectangular.strip_prefix("pat").unwrap_or(&rectangular)); + h.frame(); + let browser = crate::scenario::rect_of("browser:card").unwrap(); + let in_browser = text_left(&h, &label, browser) - text_left(&h, "Patterned Sketch", browser); + let sketch_row = crate::scenario::rect_of("history:feature:Patterned Sketch").unwrap(); + let pattern_y = crate::scenario::handle_at(&format!("timeline:sketch-pattern:{sketch}:{rectangular}")).unwrap().y; + let pattern_row = egui::Rect::from_x_y_ranges(sketch_row.x_range(), pattern_y - 8.0..=pattern_y + 8.0); + let in_history = text_left(&h, &label, pattern_row) - text_left(&h, "Patterned Sketch", sketch_row); + assert!((in_history - in_browser).abs() <= 2.0, "History steps in {in_history}, the browser {in_browser}"); + } } diff --git a/crates/ui-egui/src/sketch_report.rs b/crates/ui-egui/src/sketch_report.rs new file mode 100644 index 0000000000..77ad74bff6 --- /dev/null +++ b/crates/ui-egui/src/sketch_report.rs @@ -0,0 +1,169 @@ +//! The Sketch report window (#6262): what `sketch.inspect` says about the active sketch, for +//! people. The solver status and degrees of freedom left, how many curves, points and +//! constraints the sketch has, and which curves and points are not fully constrained yet. +//! Copy JSON puts the whole `sketch.inspect` result on the clipboard. `ui.start` of +//! `sketch.inspect` (its ribbon button) opens it; `engine.execute` still only returns the JSON. +//! The report follows edits while it is open. + +use serde_json::Value; + +use crate::SolveApp; +use crate::ui_kit; + +/// Rows of unconstrained curves and points shown before the list scrolls. +const LIST_ROWS: f32 = 8.0; + +/// The open report: the last `sketch.inspect` result, or why there is none. +pub struct SketchReport { + /// The session revision the report was read at. + pub revision: u64, + /// The sketch the report is about (the active one when the window opened). + pub sketch: Option, + pub result: Result, +} + +fn read(app: &mut SolveApp, sketch: Option) -> Result { + let params = match sketch { + Some(id) => serde_json::json!({ "sketch": id }), + None => serde_json::json!({}), + }; + app.session.execute("sketch.inspect", ¶ms).map_err(|e| e.to_string()) +} + +/// Open the report on the active sketch. +pub fn open(app: &mut SolveApp) { + let sketch = app.session.active_sketch; + let result = read(app, sketch); + app.sketch_report = Some(SketchReport { revision: app.session.revision, sketch, result }); +} + +/// The ids of the entries in `list` that are not fully constrained. +pub fn loose(v: &Value, list: &str) -> Vec { + v[list] + .as_array() + .map(|a| { + a.iter() + .filter(|e| !e["fully_constrained"].as_bool().unwrap_or(false)) + .map(|e| match e["type"].as_str() { + Some(kind) => format!("{} ({kind})", e["id"].as_str().map(str::to_string).unwrap_or_else(|| e["id"].to_string())), + None => e["id"].as_str().map(str::to_string).unwrap_or_else(|| e["id"].to_string()), + }) + .collect() + }) + .unwrap_or_default() +} + +fn count(v: &Value, list: &str) -> usize { + v[list].as_array().map_or(0, Vec::len) +} + +/// The solver status as a word ("well", "under", …). +fn status(v: &Value) -> String { + match &v["status"] { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Draw the window when it is open. Called once per frame. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut r) = app.sketch_report.take() else { return }; + if r.revision != app.session.revision { + r.revision = app.session.revision; + r.result = read(app, r.sketch); + } + let title = crate::i18n::tr_label("Sketch Report"); + let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_sketch_report"), "inspect", title) + .fields() + .actions("", crate::i18n::tr("ui.close"), None) + .show(ctx, true, |ui| match &r.result { + Err(e) => { + ui_kit::text(ui, e, ui_kit::Text::Error("error")); + } + Ok(v) => { + let name = v["name"].as_str().unwrap_or_default(); + ui_kit::row(ui, crate::i18n::tr_label("Sketch"), |ui| ui_kit::text(ui, name, ui_kit::Text::Cell)); + ui_kit::row(ui, crate::i18n::tr_label("Status"), |ui| ui_kit::text(ui, &status(v), ui_kit::Text::Cell)); + let dof = v["dof"].as_u64().map(|d| d.to_string()).unwrap_or_else(|| v["dof"].to_string()); + ui_kit::row(ui, crate::i18n::tr_label("Degrees of freedom"), |ui| ui_kit::text(ui, &dof, ui_kit::Text::Cell)); + let construction = v["curves"].as_array().map_or(0, |a| a.iter().filter(|c| c["construction"].as_bool() == Some(true)).count()); + let curves = + if construction > 0 { format!("{} ({construction} construction)", count(v, "curves")) } else { count(v, "curves").to_string() }; + ui_kit::row(ui, crate::i18n::tr_label("Curves"), |ui| ui_kit::text(ui, &curves, ui_kit::Text::Cell)); + for (label, list) in [("Points", "points"), ("Constraints", "constraints"), ("Profiles", "profiles")] { + ui_kit::row(ui, crate::i18n::tr_label(label), |ui| ui_kit::text(ui, &count(v, list).to_string(), ui_kit::Text::Cell)); + } + let (curves, points) = (loose(v, "curves"), loose(v, "points")); + if curves.is_empty() && points.is_empty() { + ui_kit::text(ui, crate::i18n::tr_label("Everything is fully constrained."), ui_kit::Text::Note("all_constrained")); + } else { + let heading = format!("{} ({})", crate::i18n::tr_label("Not fully constrained"), curves.len() + points.len()); + ui_kit::section(ui, "sc_sketch_report_loose", &heading, true, |ui| { + egui::ScrollArea::vertical().max_height(ui_kit::row_height() * LIST_ROWS).show(ui, |ui| { + for (label, ids) in [("Curve", &curves), ("Point", &points)] { + for id in ids { + ui_kit::row(ui, crate::i18n::tr_label(label), |ui| ui_kit::text(ui, id, ui_kit::Text::Cell)); + } + } + }); + }); + } + if ui_kit::icon_label_button(ui, crate::i18n::tr_label("Copy JSON"), "copy", true, false).clicked() { + ctx.copy_text(serde_json::to_string_pretty(v).unwrap_or_default()); + } + } + }); + if closed || action != ui_kit::Action::None { + return; + } + app.sketch_report = Some(r); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::scenario::Harness; + + #[test] + fn the_ribbon_button_opens_a_report_that_follows_the_sketch() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.line", json!({"points": [[0, 0], [10, 0]]})).unwrap(); + h.step(&json!({"start": "sketch.inspect"})).unwrap(); + h.frames(2); + let r = h.app.sketch_report.as_ref().expect("the button opens the report"); + let v = r.result.as_ref().expect("the active sketch is inspected"); + assert_eq!(v["curves"].as_array().map(Vec::len), Some(1)); + assert_eq!(super::loose(v, "curves").len(), 1, "a free line is not fully constrained"); + h.app.run("sketch.line", json!({"points": [[10, 0], [10, 10]]})).unwrap(); + h.frames(2); + let v = h.app.sketch_report.as_ref().unwrap().result.as_ref().unwrap(); + assert_eq!(v["curves"].as_array().map(Vec::len), Some(2), "the report follows edits"); + // Closing it changes nothing in the design. + let undo = h.app.session.undo.len(); + h.frames(4); + assert!(crate::scenario::handle_at("dialog:cancel").is_some(), "the window is drawn"); + h.step(&json!({"click": {"handle": "dialog:cancel"}})).unwrap(); + h.frames(2); + assert!(h.app.sketch_report.is_none()); + assert_eq!(h.app.session.undo.len(), undo); + } + + #[test] + fn the_command_is_on_the_sketch_tab() { + let spec = solidcraft_engine::find_command("sketch.inspect").unwrap(); + assert_eq!((spec.tab, spec.panel), ("SKETCH", "INSPECT")); + // While a sketch is open the Sketch tab shows it, not the Inspect tab's drop-down. + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.frames(3); + let at = crate::scenario::handle_at("toolbar:sketch.inspect").expect("the button is on the ribbon"); + h.step(&json!({"click": [at.x, at.y]})).unwrap(); + h.frames(2); + assert!(h.app.sketch_report.is_some(), "clicking the ribbon button opens the report"); + } +} diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index 6cf025a5a5..9089df35e2 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -28,6 +28,9 @@ const ANGLE: usize = 3; const ANGLE_END: usize = 4; const LINE_SPACING: usize = 5; +/// Starts the words' expression in the Text field (`='W' + number(Width)`, #9281). +const FORMULA: char = '='; + /// No input is active: a click on the canvas picks no path until the Path chip is clicked. const NO_INPUT: usize = usize::MAX; @@ -36,8 +39,11 @@ const NO_INPUT: usize = usize::MAX; pub struct Tx { sketch: u64, pub(crate) link: String, + /// The words as typed, or `=` and the expression they follow. pub(crate) text: String, initial_text: String, + /// The saved words follow an expression: an untouched Text field keeps it. + initial_formula: bool, /// The font as typed: an installed font's family name or a font file's path; empty is the built-in font. pub(crate) font: String, initial_font: String, @@ -70,8 +76,8 @@ pub struct Tx { shown: Option, } -/// A text source as the dialog sees it: text, font (empty: built-in), its values, its style and -/// the expressions behind its values. +/// A text source as the dialog sees it: text (`=` and its expression when the words follow one), +/// font (empty: built-in), its values, its style and the expressions behind its values. type Source = (String, String, [f64; FIELDS], TextStyle, [Option; FIELDS]); /// What the Text tool puts down, ready to be typed over. @@ -222,8 +228,15 @@ fn source(app: &SolveApp, sketch: u64, link: &str) -> Option { let source = sk.link(link).filter(|source| source.kind == LinkKind::Text)?; let LinkSource::Text { text, at, height, angle, font, style, exprs } = &source.source else { return None }; let values = [at.x, at.y, *height, *angle, style.letter_spacing, style.line_spacing]; + let text = exprs.text.as_ref().map_or_else(|| text.clone(), |e| format!("{FORMULA}{e}")); let exprs = [exprs.x.clone(), exprs.y.clone(), exprs.height.clone(), exprs.angle.clone(), None, None]; - Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone(), exprs)) + Some((text, font.clone().unwrap_or_default(), values, style.clone(), exprs)) +} + +/// Whether the saved words of `link` follow an expression. +fn formula_saved(app: &SolveApp, sketch: u64, link: &str) -> bool { + let source = app.session.doc.sketch(sketch).ok().and_then(|sk| sk.link(link).map(|l| l.source.clone())); + matches!(source, Some(LinkSource::Text { exprs, .. }) if exprs.text.is_some()) } /// The fields as shown: a value, or the expression behind it. @@ -281,6 +294,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option { let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units); let values = fields(&original, &exprs, unit, scale); let mut tx = Tx { + initial_formula: formula_saved(app, sketch, &link), sketch, link, initial_text: text.clone(), @@ -468,6 +482,7 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool { k.text = text.clone(); } k.initial_text = text; + k.initial_formula = formula_saved(app, k.sketch, &k.link); } if font != k.initial_font { if k.font == k.initial_font { @@ -486,7 +501,7 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], activ if ui.memory(|m| m.has_focus(id)) { enter |= ui.input_mut(|i| !i.modifiers.shift && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); } - let response = ui_kit::multiline_text_field(ui, "Text", id, &mut k.text); + let response = ui_kit::multiline_text_field(ui, "Text", id, &mut k.text).on_hover_text(crate::i18n::tr("sketch_text.formula")); crate::scenario::publish_handle("dialog:Text", response.rect.center()); if k.focus { response.request_focus(); @@ -678,11 +693,18 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result { TextAlign::Right => "right", }; let mut params = json!({ - "link": k.link, "text": k.text, "at": [sent_x, sent_y], "height": sent_height, + "link": k.link, "at": [sent_x, sent_y], "height": sent_height, "bold": k.style.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, }); + // `=` starts the words' expression; an untouched text keeps what it was, so a typed text that + // happens to start with `=` stays typed. + let untouched = k.text == k.initial_text; + match k.text.trim_start().strip_prefix(FORMULA).filter(|_| !untouched || k.initial_formula) { + Some(e) => params["text_expression"] = json!(e.trim()), + None => params["text"] = json!(k.text), + } // Omitting an unchanged angle keeps its exact saved radians (or its expression). if k.values.get(ANGLE) != k.initial.get(ANGLE) { params["angle"] = sent_angle; @@ -1159,6 +1181,60 @@ mod tests { assert!(editing(&h.app), "the dialog stays open on a field that cannot be used"); } + fn words_of(h: &Harness, link: &str) -> String { + let sid = h.app.session.active_sketch.unwrap(); + let LinkSource::Text { text, .. } = h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source.clone() else { panic!("text source") }; + text + } + + /// #9281: a Text starting with `=` is the words' expression: OK keeps it; reopened, the field + /// shows it with its `=` and an untouched OK keeps it; typed words replace it; a typed text + /// that happens to start with `=` stays typed; the rebuilt design's words follow the parameter. + #[test] + fn a_text_starting_with_equals_builds_its_words_from_parameters() { + let (mut h, link, _) = setup(); + let formula = |h: &mut Harness| { + h.app.start("sketch.edit_text"); + h.frames(3); + replace(h, "dialog:Text", "='W' + number(Width)"); + ok(h); + assert!(!editing(&h.app), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone())); + assert_eq!(exprs_of(h, &link).text.as_deref(), Some("'W' + number(Width)")); + }; + let reopen = |h: &mut Harness| { + select_text(h, &link); + h.app.start("sketch.edit_text"); + h.frames(3); + }; + h.app.run("parameters.add", json!({"name":"Width","expression":"85 mm"})).unwrap(); + formula(&mut h); + assert_eq!(words_of(&h, &link), "W85"); + + reopen(&mut h); + assert_eq!(tx(&h.app).text, "='W' + number(Width)"); + ok(&mut h); + assert_eq!(exprs_of(&h, &link).text.as_deref(), Some("'W' + number(Width)")); + + reopen(&mut h); + replace(&mut h, "dialog:Text", "Plain"); + ok(&mut h); + assert_eq!((exprs_of(&h, &link).text, words_of(&h, &link).as_str()), (None, "Plain")); + + h.app.run("sketch.edit_text", json!({"link": link, "text": "=5"})).unwrap(); + reopen(&mut h); + ok(&mut h); + assert_eq!((exprs_of(&h, &link).text, words_of(&h, &link).as_str()), (None, "=5")); + + select_text(&mut h, &link); + formula(&mut h); + let sid = h.app.session.active_sketch.unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("parameters.change", json!({"name":"Width","expression":"90 mm"})).unwrap(); + let state = h.app.session.model.state(); + let LinkSource::Text { text, .. } = &state.sketch(sid).unwrap().sketch.link(&link).unwrap().source else { panic!("text source") }; + assert_eq!(text, "W90"); + } + #[test] fn fields_take_expressions_with_units_and_parameters() { let (mut h, link, _) = setup(); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index fcdb97edc2..1d1cd616f8 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -9,7 +9,7 @@ use std::cell::{Cell, RefCell}; use egui::{Pos2, Stroke}; use serde_json::{Value, json}; -use solidcraft_engine::geom::{Vec2, Vec3}; +use solidcraft_engine::geom::{Plane, Vec2, Vec3}; use crate::SolveApp; use crate::tool_modifiers::{Audit, Binding, Match, ModAction, ModifierCombo, Scope, ToolModifiers, Trigger}; @@ -37,6 +37,16 @@ const EXTEND_BINDINGS: &[Binding] = &[ /// Per-tool modifier descriptors owned by this host file. pub const MODIFIERS: &[ToolModifiers] = &[ + ToolModifiers { tool: "sketch.construction_line", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.horizontal", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.vertical", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.angle", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.parallel", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.perpendicular", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.tangent_external", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.tangent_at", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.tangent_two", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, + ToolModifiers { tool: "sketch.construction_line.bisector", scopes: DRAW_SCOPES, bindings: &[], audit: Audit::Implemented }, ToolModifiers { tool: "sketch.project", scopes: NONE, bindings: &[], audit: Audit::VerifiedNoOp }, ToolModifiers { tool: "sketch.intersect", scopes: NONE, bindings: &[], audit: Audit::VerifiedNoOp }, ToolModifiers { tool: "sketch.include_3d", scopes: NONE, bindings: &[], audit: Audit::VerifiedNoOp }, @@ -281,6 +291,10 @@ fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Commands that start with a step of their own: Canvas and Decal ask for the image first, /// Edit Canvas opens the canvas panel. False when the command should not go on. pub fn start_hook(app: &mut SolveApp, id: &str) -> bool { + if id == "inspect.section" && app.preferences.viewport_section_status && app.session.section.is_some() { + let _ = app.run(id, json!({"clear":true})); + return false; + } if id == "sketch.intersect" && let Some(dialog) = crate::dialogs::Dialog::for_command(app, id) { @@ -461,6 +475,18 @@ pub(crate) fn constrain_lined(app: &mut SolveApp, tool: &mut Tool, placed: &[Vec app.session.undo = history; } +/// The point of `poly` nearest `p`; None for fewer than two points. +fn nearest_on_polyline(poly: &[Vec2], p: Vec2) -> Option { + poly.windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let ab = b - a; + let t = if ab.len2() > 0.0 { ((p - a).dot(ab) / ab.len2()).clamp(0.0, 1.0) } else { 0.0 }; + a + ab * t + }) + .min_by(|x, y| x.dist(p).total_cmp(&y.dist(p))) +} + /// Snap a cursor point on the sketch plane to what is inferred while drawing: a crossing of /// two curves, a line's midpoint, or alignment (horizontal/vertical) with the previous point /// of the tool. None: nothing near, use the grid. @@ -553,13 +579,20 @@ pub fn refine_snap( best = Some((i.dist, i.label(), i.at)); mid_of = i.arg; } - // On a curve (coincident with it). + // On a curve (coincident with it). A spline, ellipse or conic has no exact shape here: the + // nearest point of the polyline it is drawn with, which the click's point-on-curve + // constraint then puts on the curve itself (#9321). if !mid_only && best.is_none() { for (i, c) in sk.curves.iter().enumerate() { - let Some(sh) = sk.shape(i) else { continue }; - let d = sh.dist(lp); + let (d, q) = match sk.shape(i) { + Some(sh) => (sh.dist(lp), sh.project(lp)), + None if c.kind.is_freeform() => { + let Some(q) = nearest_on_polyline(&sk.polyline(i), lp) else { continue }; + (q.dist(lp), q) + } + None => continue, + }; if d < tol * 0.8 && best.is_none_or(|x| d < x.0) { - let q = sh.project(lp); best = Some((d, "On", q)); mid_of = Some(format!("{}:{},{}", c.id, q.x, q.y)); } @@ -701,6 +734,7 @@ pub fn delete_glyph(app: &mut SolveApp) -> bool { /// What the extra tools do with clicks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Mode { + Construction, /// Each click on model geometry runs the command with that reference. ModelRef, /// Click a curve of the active sketch where it should change. @@ -724,6 +758,9 @@ pub fn takes(id: &str, hit: &Hit) -> bool { } fn mode(id: &str) -> Option { + if solidcraft_engine::cmd::construction_line_mode(id).is_some() { + return Some(Mode::Construction); + } Some(match id { "sketch.project" | "sketch.intersect" | "sketch.include_3d" | "sketch.fit_curves_to_section" | "sketch.isoparametric_curve" => Mode::ModelRef, "inspect.curvature_comb" | "canvas.insert" | "canvas.decal" => Mode::ModelRef, @@ -772,6 +809,9 @@ pub fn image_placement_finished(tool: &Tool) -> bool { } pub fn hint(id: &str) -> Option { + if solidcraft_engine::cmd::construction_line_mode(id).is_some() { + return Some(crate::sketch_construction::hint(id)); + } Some( match id { "sketch.project" => "Project: click edges, faces, vertices, other sketches' curves, axes or planes", @@ -962,6 +1002,7 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { let at = sketch_point_at(app, proj, pos); let cmd = tool.cmd; match m { + Mode::Construction => crate::sketch_construction::on_click(app, tool, &hits, at), Mode::ModelRef if cmd == "inspect.curvature_comb" => { let params = hits.iter().find_map(|h| match h { Hit::SketchCurve { sketch, id, .. } => Some(json!({"sketch": sketch, "curves": [id]})), @@ -1213,6 +1254,10 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { /// Typed values are checked first, so a rejected one stays in its box to be corrected rather /// than reaching the command. pub(crate) fn place_point(app: &mut SolveApp, tool: &mut Tool, p: (Vec2, Option)) { + if solidcraft_engine::cmd::construction_line_mode(tool.cmd).is_some() { + crate::sketch_construction::place_point(app, tool, p); + return; + } if tool.cmd == "sketch.circle.two_tangent" && tool.picks.len() == 2 { if let Err(error) = crate::sketch_dims::validate(app, tool) { app.set_status(error, true); @@ -1387,6 +1432,10 @@ fn extend_preview_piece(app: &SolveApp, sketch: u64, id: &str) -> Option bool { + !hide_when_zoomed_out || px_per_mm >= MIN_GLYPH_PX_PER_MM +} + +/// Pixels on screen for one millimetre along the sketch plane's U axis, near the plane origin. +fn sketch_px_per_mm(plane: &Plane, proj: &Proj) -> Option { + let a = proj.to_screen(plane.to_world(Vec2::ZERO))?; + let b = proj.to_screen(plane.to_world(Vec2::new(1.0, 0.0)))?; + Some(a.distance(b)) +} + fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option) { let Some(sid) = active_sketch(app) else { return }; let st = app.session.world_state(); @@ -1478,6 +1544,13 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option before.distance); + fn reverse_zoom_setting_flips_the_wheel_and_leaves_the_cap() { + let wheel_zooms_in = |reverse: bool| { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.zoom_reverse = reverse; + h.app.preferences.smooth_wheel_zoom = false; + h.call("ui.move", serde_json::json!({"x":800,"y":500})); + let before = h.app.cam.half_height(); + h.call("ui.scroll", serde_json::json!({"x":800,"y":500,"delta":120})); + h.app.cam.half_height() < before + }; + assert!(wheel_zooms_in(false)); + assert!(!wheel_zooms_in(true), "the wheel turns around"); + // The cap's zoom axis pushed forward with the wheel reversed or not, and with the cap's own + // invert switch for that axis on or off. + let cap_distance = |wheel_reversed: bool, cap_inverted: bool| { + let mut app = app(); + app.preferences.zoom_reverse = wheel_reversed; + app.preferences.space_mouse_reverse[1] = cap_inverted; + let ctx = window(true); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let before = app.cam.distance; + frame(&mut app, &ctx, DriverMapping::default(), Axes { translation: [0, 350, 0], rotation: [0; 3] }, 0.02); + (before, app.cam.distance) + }; + let (before, normal) = cap_distance(false, false); + assert!(normal < before, "pushing the cap forward zooms in"); + assert_eq!(cap_distance(true, false).1, normal, "the cap zooms the same way with the wheel reversed"); + assert!(cap_distance(false, true).1 > before, "the cap's own invert switch turns it around"); + assert_eq!(cap_distance(true, true).1, cap_distance(false, true).1); } #[test] fn object_mode_tilts_roll_and_twists_spin() { let before = Camera::default(); let mut tilt = before; - navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false); + navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false); assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch)); assert_ne!(tilt.roll, before.roll); let mut twist = before; - navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false); + navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false); assert_ne!(twist.yaw, before.yaw); assert_eq!((twist.pitch, twist.roll), (before.pitch, before.roll)); // The physical tilt right (Ry−) rolls one way and the clockwise twist (Rz+) spins the diff --git a/crates/ui-egui/src/space_mouse_offer.rs b/crates/ui-egui/src/space_mouse_offer.rs index 6f2ac9e697..07013f06fb 100644 --- a/crates/ui-egui/src/space_mouse_offer.rs +++ b/crates/ui-egui/src/space_mouse_offer.rs @@ -1,6 +1,7 @@ //! The offer to calibrate a 3D mouse that has no calibration yet: people don't know calibration //! exists, and it is what fixes swapped or reversed axes. It shows once per device, as one of the -//! shared toasts (`viewport::transient_messages`), until the user calibrates or closes it. +//! shared toasts (`viewport::transient_messages`), until the user calibrates or closes it with the +//! toast's close (×), whose countdown ring shows the time it has left (#7971). use std::collections::BTreeSet; @@ -26,7 +27,7 @@ pub fn swap_offered(device: &str, drivers: Option bool { now - since >= SHOW_SECONDS } -/// What the offer's toast can do when clicked. +/// What the offer's toast can do when clicked: its buttons, and `Close`, its close (×). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Action { /// Opens Preferences at Navigation and starts the calibration window. Calibrate, - /// Records this device as offered. + /// Records this device as offered: the toast's close (×), never a button (#7971). Close, /// Records that no offer is wanted any more. Never, @@ -66,7 +67,15 @@ impl Action { Self::Calibrate => "ui.3d_mouse_calibrate", Self::Close => "ui.close", Self::Never => "ui.3d_mouse_offer_never", - Self::Swap => "ui.3d_mouse_offer_swap", + Self::Swap => "ui.3d_mouse_use_direct", + } + } + + /// The button's hover text, as a message id: what a short label leaves out. + pub fn tip(self) -> Option<&'static str> { + match self { + Self::Swap => Some("ui.3d_mouse_use_direct_tip"), + Self::Calibrate | Self::Close | Self::Never => None, } } } @@ -94,8 +103,8 @@ pub struct Offer { /// `file_picker_busy`, `file_load.busy`, `prefs_window.open`, `space_mouse.calibrating`, /// `issue_report::cad_input_blocked`. /// - Writes, only its own: `space_mouse.offer_since`, `space_mouse.offer_hidden`; -/// `space_mouse_offered` and `space_mouse_offer_never` (preferences) when the user clicks Close, -/// Calibrate or Never. +/// `space_mouse_offered` and `space_mouse_offer_never` (preferences) when the user closes it (×), +/// or clicks Calibrate or Never. /// - Calibrate opens Preferences at Navigation and starts the calibration window /// (`prefs::open_at`, `space_mouse_calibration::start`), exactly as Preferences does. Swap (offered only /// when the user chose spacenavd and a device is there) sets `space_mouse_driver` to Direct, the same @@ -132,10 +141,18 @@ pub fn due(app: &mut SolveApp, ctx: &egui::Context) -> Option { if swap_offered(&device, app.space_mouse.drivers, app.preferences.space_mouse_driver) { actions.push(Action::Swap); } - actions.extend([Action::Close, Action::Never]); + // Close is the toast's close (×), not a button, so the buttons fit one row (#7971). + actions.push(Action::Never); Some(Offer { device, actions }) } +/// The share of the offer's time left now, 1 to 0, for the countdown ring around its close (×). +/// Pointing at it (`keep`) starts its time again, so the ring is full while it is pointed at. +pub(crate) fn left(app: &SolveApp, ctx: &egui::Context) -> f32 { + let now = ctx.input(|i| i.time); + app.space_mouse.offer_since.as_ref().map_or(1.0, |(_, since)| crate::viewport::countdown_left(*since, now, SHOW_SECONDS)) +} + /// The toast was drawn at `rect`: a pointer on it keeps it from expiring. pub fn keep(app: &mut SolveApp, ctx: &egui::Context, rect: egui::Rect) { if ctx.input(|i| i.pointer.hover_pos()).is_some_and(|pos| rect.contains(pos)) { @@ -297,7 +314,10 @@ mod tests { fn closing_the_offer_records_the_device_so_it_does_not_come_back() { let mut h = offered(); assert!(crate::scenario::rect_of("toast:space_mouse_offer").is_some(), "the notice is drawn"); - press(&mut h, "Close"); + // Its close (×), as the Close button it replaces (#7971). + h.frames(3); + h.step(&serde_json::json!({"click": {"handle": "toast:space_mouse_offer:close"}})).unwrap(); + h.frames(2); assert!(h.app.preferences.space_mouse_offered.contains("hid 046d:c62b")); assert!(h.app.space_mouse.calibrating.is_none()); h.frames(3); @@ -476,7 +496,7 @@ mod preferences_tests { assert!(h.app.space_mouse.offer_since.is_some(), "{how}: the offer is up"); assert_eq!(state(&h), before, "{how}: showing it changes nothing else"); if how == "close" { - h.step(&serde_json::json!({"widget": "Close"})).unwrap(); + h.step(&serde_json::json!({"click": {"handle": "toast:space_mouse_offer:close"}})).unwrap(); } else if let Some((device, _)) = h.app.space_mouse.offer_since.take() { h.app.space_mouse.offer_since = Some((device, -1000.0)); } @@ -501,7 +521,7 @@ mod preferences_tests { fn swapping_to_the_direct_driver_sets_it_and_leaves_the_offered_set_alone() { let mut h = spacenavd_harness(); assert!(h.app.space_mouse.offer_since.is_some(), "the card is up"); - h.step(&serde_json::json!({"widget": "Swap to Hidraw (Direct Driver)"})).unwrap(); + h.step(&serde_json::json!({"widget": "Use Direct Driver"})).unwrap(); h.frames(3); assert_eq!(h.app.preferences.space_mouse_driver, crate::prefs::SpaceMouseDriver::Direct); assert!(h.app.preferences.space_mouse_offered.is_empty(), "the new source may still be offered"); @@ -510,7 +530,47 @@ mod preferences_tests { let mut h = spacenavd_harness(); h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); h.frames(3); - assert!(h.step(&serde_json::json!({"widget": "Swap to Hidraw (Direct Driver)"})).is_err()); + assert!(h.step(&serde_json::json!({"widget": "Use Direct Driver"})).is_err()); + } + + /// The card has no Close button (#7971): its × closes it as the button did, its three buttons + /// fit one row, and the ring around the × follows the share of its 30 seconds left. + #[test] + fn the_offer_closes_with_its_x_its_three_buttons_fit_one_row_and_its_ring_follows_its_time() { + let mut h = spacenavd_harness(); + run(&mut h, 1.0); + assert!(card_up(&h)); + let spots: Vec<[f64; 2]> = ["Calibrate…", "Use Direct Driver", "Never show again"] + .iter() + .map(|label| h.widget(label).unwrap_or_else(|| panic!("{label} is on the card"))) + .collect(); + let card = crate::scenario::rect_of("toast:space_mouse_offer").unwrap(); + assert!(spots.iter().all(|p| (p[1] - spots[0][1]).abs() < 1.0), "one row: {spots:?} in {card:?}"); + // Laid out with the buttons' drawn widths: the main action is flush with the row's right + // edge (the card's, less its 12-point margin), and the buttons sit a gap apart. + let button = |i: usize| crate::scenario::rect_of(&format!("toast:space_mouse_offer:button:{i}")).unwrap(); + let (calibrate, direct, never) = (button(0), button(1), button(2)); + assert!((calibrate.right() - (card.right() - 12.0)).abs() < 0.5, "flush right: {calibrate:?} in {card:?}"); + let gap = crate::theme::Tokens::get().space_8; + assert!((calibrate.left() - direct.right() - gap).abs() < 0.5, "{direct:?} then {calibrate:?}"); + assert!((direct.left() - never.right() - gap).abs() < 0.5, "{never:?} then {direct:?}"); + assert!(never.left() >= card.left() + 28.0 - 0.5, "inside the row: {never:?} in {card:?}"); + // The only control named Close is the ×, in the card's top-right corner. + let close = h.at(&serde_json::json!({"handle": "toast:space_mouse_offer:close"})).unwrap(); + let near = |p: [f64; 2]| (p[0] - close[0]).abs() < 1.0 && (p[1] - close[1]).abs() < 1.0; + assert!(h.widget("Close").is_some_and(near), "the × is the Close: {:?} at {close:?}", h.widget("Close")); + assert!(close[0] as f32 > card.center().x && (close[1] as f32) < spots[0][1] as f32, "top right: {close:?} in {card:?}"); + // The ring: about 1 of 30 seconds gone, then 11. + let left = |h: &crate::scenario::Harness| super::left(&h.app, &h.ctx); + assert!((left(&h) - (1.0 - 1.0 / 30.0)).abs() < 0.03, "{}", left(&h)); + run(&mut h, 10.0); + assert!((left(&h) - (1.0 - 11.0 / 30.0)).abs() < 0.03, "{}", left(&h)); + // Closing records the device, as Close did. + h.step(&serde_json::json!({"click": {"handle": "toast:space_mouse_offer:close"}})).unwrap(); + h.frames(2); + assert!(h.app.preferences.space_mouse_offered.contains("spacenavd")); + assert!(!card_up(&h)); + assert_eq!(h.app.preferences.space_mouse_driver, crate::prefs::SpaceMouseDriver::Spacenavd, "nothing else"); } #[test] @@ -652,7 +712,7 @@ mod preferences_tests { /// card's four buttons does anything. #[test] fn a_click_after_a_guard_became_active_does_nothing_behind_it() { - for widget in ["Calibrate…", "Close", "Never show again", "Swap to Hidraw (Direct Driver)"] { + for widget in ["Calibrate…", "Close", "Never show again", "Use Direct Driver"] { let mut h = spacenavd_harness(); run(&mut h, 2.0); assert!(card_up(&h), "{widget}"); @@ -681,7 +741,8 @@ mod preferences_tests { let mut h = spacenavd_harness(); run(&mut h, 2.0); let offer = due(&mut h.app, &h.ctx).expect("due"); - assert!(offer.actions.contains(&action), "{action:?} is on the card"); + // Close is the card's × rather than one of its buttons (#7971). + assert!(action == Action::Close || offer.actions.contains(&action), "{action:?} is on the card"); (h, offer) }; // Control. @@ -777,7 +838,7 @@ mod preferences_tests { h.app.space_mouse.device = Some("spacenavd".into()); run(&mut h, 2.0); assert!(card_up(&h), "the first record shows the offer"); - assert!(h.widget("Swap to Hidraw (Direct Driver)").is_some(), "with a direct device there, Swap shows"); + assert!(h.widget("Use Direct Driver").is_some(), "with a direct device there, Swap shows"); // Hidden for the run (a status toast), it stays hidden when the device is seen again. h.app.status = Some(("Done".into(), crate::now_ms(), false)); run(&mut h, 1.0); @@ -794,14 +855,14 @@ mod preferences_tests { h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); h.app.space_mouse.device = Some("spacenavd".into()); run(&mut h, 2.0); - assert!(card_up(&h) && h.widget("Swap to Hidraw (Direct Driver)").is_none(), "no Swap under Automatic"); + assert!(card_up(&h) && h.widget("Use Direct Driver").is_none(), "no Swap under Automatic"); // Without a direct device there is no Swap. let mut h = crate::scenario::Harness::new(); h.app.home.open = false; h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); h.app.space_mouse.device = Some("spacenavd".into()); run(&mut h, 2.0); - assert!(card_up(&h) && h.widget("Swap to Hidraw (Direct Driver)").is_none()); + assert!(card_up(&h) && h.widget("Use Direct Driver").is_none()); } /// The same device, plugged back in, gets what it had: its saved calibration applies, Preferences diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs index 6fd0f8fb57..7e683ee8e8 100644 --- a/crates/ui-egui/src/stand_up.rs +++ b/crates/ui-egui/src/stand_up.rs @@ -243,14 +243,25 @@ pub(crate) fn note_due(app: &mut SolveApp, ctx: &egui::Context) -> Option [String; 3] { - [crate::i18n::tr_label("Stand it up").to_string(), crate::i18n::tr_label("Don't ask again").to_string(), crate::i18n::tr("ui.close").to_string()] +pub(crate) fn note_labels() -> [String; 2] { + [crate::i18n::tr_label("Stand it up").to_string(), crate::i18n::tr_label("Don't ask again").to_string()] +} + +/// The share of the note's time left now, 1 to 0, for the countdown ring around its close (×). +/// Pointing at it (`note_keep`) starts its time again, so the ring is full while it is pointed at. +pub(crate) fn note_left(app: &SolveApp, ctx: &egui::Context) -> f32 { + let now = ctx.input(|i| i.time); + app.stand_up_note_since.map_or(1.0, |since| crate::viewport::countdown_left(since, now, NOTE_SECONDS)) } /// The note was drawn at `rect`: a pointer on it keeps it from going. diff --git a/crates/ui-egui/src/surface_clamp_tests.rs b/crates/ui-egui/src/surface_clamp_tests.rs index 84b4aaa8e1..b635d4739d 100644 --- a/crates/ui-egui/src/surface_clamp_tests.rs +++ b/crates/ui-egui/src/surface_clamp_tests.rs @@ -1,8 +1,10 @@ //! The window surface is clamped to the device's largest texture (#869): a window moved between //! monitors of different scale asked for 11512 x 6808 against a maximum of 8192 and the -//! surface configure was a validation panic. The clamp lives in the vendored egui-wgpu. +//! surface configure was a validation panic. The clamp lives in the vendored egui-wgpu, and so +//! does the present mode of a window other than the main one (the agent chat popped out). -use egui_wgpu::clamp_surface_extent; +use egui_wgpu::wgpu::PresentMode; +use egui_wgpu::{clamp_surface_extent, viewport_present_mode}; #[test] fn a_window_that_fits_is_unchanged() { @@ -39,3 +41,24 @@ fn extreme_sizes_never_give_zero_or_exceed_the_limit() { assert!(s > 0.0 && s <= 1.0); } } + +#[test] +fn a_popped_out_window_presents_without_waiting_for_the_display() { + // Mesa's Vulkan on Wayland: the chat's window took the main window down to a crawl while + // both waited for vsync in one frame. + let wayland = [PresentMode::Fifo, PresentMode::Mailbox, PresentMode::Immediate]; + assert_eq!(viewport_present_mode(false, PresentMode::AutoVsync, &wayland), PresentMode::Mailbox); + assert_eq!(viewport_present_mode(false, PresentMode::Fifo, &[PresentMode::Fifo, PresentMode::Immediate]), PresentMode::Immediate); + // The main window keeps its vsync: it paces both. + assert_eq!(viewport_present_mode(true, PresentMode::AutoVsync, &wayland), PresentMode::AutoVsync); +} + +#[test] +fn a_surface_that_only_waits_and_a_chosen_mode_without_vsync_are_kept() { + assert_eq!(viewport_present_mode(false, PresentMode::AutoVsync, &[PresentMode::Fifo]), PresentMode::AutoVsync); + assert_eq!(viewport_present_mode(false, PresentMode::Fifo, &[]), PresentMode::Fifo); + for configured in [PresentMode::AutoNoVsync, PresentMode::Immediate, PresentMode::Mailbox] { + assert_eq!(viewport_present_mode(false, configured, &[PresentMode::Fifo, PresentMode::Mailbox]), configured); + assert_eq!(viewport_present_mode(true, configured, &[PresentMode::Fifo]), configured); + } +} diff --git a/crates/ui-egui/src/test_boat.rs b/crates/ui-egui/src/test_boat.rs index 74eaf0f614..4683f20e9d 100644 --- a/crates/ui-egui/src/test_boat.rs +++ b/crates/ui-egui/src/test_boat.rs @@ -109,17 +109,8 @@ impl Boat { /// `motion` for `seconds` on the boat's camera in a box `aspect` wide for its height of `height` /// points, with pan and zoom held so a sphere of `radius` stays inside it. -pub(crate) fn step( - cam: &mut Camera, - motion: crate::space_mouse::Motion, - seconds: f64, - height: f64, - radius: f64, - aspect: f64, - reverse_zoom: bool, - free_orbit: bool, -) { - crate::navigation::device_camera(cam, motion, seconds, height, true, reverse_zoom, free_orbit, None, None); +pub(crate) fn step(cam: &mut Camera, motion: crate::space_mouse::Motion, seconds: f64, height: f64, radius: f64, aspect: f64, free_orbit: bool) { + crate::navigation::device_camera(cam, motion, seconds, height, true, free_orbit, None, None); cam.fov = FOV; // Zoom within limits: the nearest still shows the whole boat in the shorter side of the box. let shorter = aspect.min(1.0); @@ -171,14 +162,7 @@ pub(crate) fn project(model: &Model, cam: &Camera, rect: Rect) -> Frame { } /// Draws the boat in a box of `size` and moves it by this frame's motion. `boat` is the page's state. -pub(crate) fn show( - ui: &mut egui::Ui, - boat: &mut Option, - motion: Option, - reverse_zoom: bool, - free_orbit: bool, - size: egui::Vec2, -) -> Option { +pub(crate) fn show(ui: &mut egui::Ui, boat: &mut Option, motion: Option, free_orbit: bool, size: egui::Vec2) -> Option { let model = model()?; let t = Tokens::get(); let now = ui.input(|i| i.time); @@ -188,7 +172,7 @@ pub(crate) fn show( state.seen = now; if let Some(motion) = motion { let aspect = f64::from(rect.width()) / f64::from(rect.height().max(1.0)); - step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, reverse_zoom, free_orbit); + step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, free_orbit); } let painter = ui.painter_at(rect); painter.rect(rect, t.radius_sm, t.dialog_body, egui::Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/test_boat_tests.rs b/crates/ui-egui/src/test_boat_tests.rs index 4c8521d7dc..2ef7c969e5 100644 --- a/crates/ui-egui/src/test_boat_tests.rs +++ b/crates/ui-egui/src/test_boat_tests.rs @@ -51,7 +51,7 @@ fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() { let aspect = 1.4; let run = |m: Motion, seconds: f64| { let mut cam = start; - step(&mut cam, m, seconds, 150.0, radius, aspect, false, false); + step(&mut cam, m, seconds, 150.0, radius, aspect, false); cam }; // Nothing moves nothing. @@ -70,17 +70,17 @@ fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() { // Held down for a long time, the limits hold: nearest, farthest, and the pan room. let mut cam = start; for _ in 0..400 { - step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false); + step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false); } assert!(cam.distance >= radius * NEAREST / 1.0 - 1e-6 && cam.target.len() <= cam.distance * (FOV / 2.0).tan() + 1e-6); let mut cam = start; for _ in 0..400 { - step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false); + step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false); } assert!(cam.distance <= radius * FARTHEST + 1e-6); // Hostile numbers leave a valid camera. let mut cam = start; - step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false, false); + step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false); assert!(cam.is_valid()); } @@ -94,7 +94,7 @@ fn test_boat_draws_inside_its_box_at_every_size_and_pose() { // Through the extremes of what the 3D mouse can do. for (translation, rotation) in [([0.0; 3], [0.0; 3]), ([1.0, -1.0, 1.0], [1.0, 0.0, 1.0]), ([-1.0, 1.0, -1.0], [0.0, 1.0, -1.0])] { for _ in 0..200 { - step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false, false); + step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false); } let frame = project(m, &cam, rect.shrink(1.0)); assert!(!frame.indices.is_empty(), "{w}x{h}: something is drawn"); @@ -141,7 +141,7 @@ fn test_boat_far_faces_come_first_and_a_page_opened_again_starts_afresh() { let moved = Some(DeviceMotion { motion: motion([0.0; 3], [1.0, 0.0, 0.0]), seconds: 0.05 }); let draw = |time: f64, motion: Option, state: &mut Option| { let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { - show(ui, state, motion, false, false, vec2(200.0, 150.0)); + show(ui, state, motion, false, vec2(200.0, 150.0)); }); output.textures_delta.clear(); }; diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 53dcf5b47e..d78b5840f1 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -406,17 +406,37 @@ pub(crate) fn status_text(name: &str, ty: &str, error: Option<&str>, warning: Op /// it isn't built then. pub(crate) fn feature_status(app: &SolveApp, id: u64) -> Option { let index = app.session.doc.feature_index(id)?; + status_at(app, index, || app.session.model.result(id)) +} + +/// The mark of the feature at `index` in the history, given how to find its result. +fn status_at<'a>(app: &SolveApp, index: usize, result: impl FnOnce() -> Option<&'a solidcraft_engine::doc::FeatureResult>) -> Option { let feature = app.session.doc.features.get(index)?; if feature.suppressed || app.session.doc.marker.is_some_and(|marker| index >= marker) { return None; } - let result = app.session.model.result(id)?; + let result = result()?; if result.error.is_some() { Some(Status::Error) } else { result.warning.as_ref().map(|_| Status::Warning) } } /// The worst mark among `ids`, with the message of the first feature that has it. pub(crate) fn worst_status(app: &SolveApp, ids: &[u64]) -> (Option, Option) { - let worst = ids.iter().filter_map(|id| feature_status(app, *id).map(|status| (status, *id))).reduce(|a, b| if b.0 > a.0 { b } else { a }); + let mark = |status: Option, id: &u64| status.map(|status| (status, *id)); + // A long folder finds each feature's place and result in tables built once: a search per + // feature made a folded long history cost the square of its length every frame. A short + // one searches, which costs less than building the tables. + let marks: Vec<(Status, u64)> = if ids.len() < 16 { + ids.iter().filter_map(|id| mark(feature_status(app, *id), id)).collect() + } else { + let places: std::collections::HashMap = app.session.doc.features.iter().enumerate().rev().map(|(i, f)| (f.id, i)).collect(); + let results = result_index(&app.session.model.results); + let status = |id: &u64| { + let index = *places.get(id)?; + status_at(app, index, || results.get(id).and_then(|k| app.session.model.results.get(*k))) + }; + ids.iter().filter_map(|id| mark(status(id), id)).collect() + }; + let worst = marks.into_iter().reduce(|a, b| if b.0 > a.0 { b } else { a }); match worst { Some((status, id)) => (Some(status), feature_message(app, id)), None => (None, None), @@ -979,6 +999,8 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) { select_range(app, &order, *id, modifiers.command || modifiers.ctrl); } else if modifiers.command || modifiers.ctrl { toggle_feature(app, *id); + } else if !modifiers.any() && crate::browser::pick_sketch_into_dialog(app, *id) { + // Extrude, Revolve or Emboss took every profile of the sketch (#6106). } else { select_one(app, *id); } @@ -1873,6 +1895,58 @@ mod tests { assert_eq!(index.get(&twice[first].id), Some(&first), "the first result of an id wins, as in Model::result"); } + /// A folder's mark is the worst of its features' own, searched for a short folder and found + /// in tables for a long one: an error over warnings, the message of the first feature with + /// the worst, none for a feature suppressed or rolled back, and a feature listed twice in the + /// history found at its first place. + #[test] + fn a_folders_mark_is_the_worst_of_its_features_own() { + use crate::ui_kit::Status; + let mut h = two_boxes(false); + let third = + json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new_body", "name": "Third", "body_name": "Three"}); + h.app.run("solid.box", third).unwrap(); + h.frames(2); + let ids: Vec = h.app.session.doc.features.iter().map(|f| f.id).collect(); + assert_eq!(ids.len(), 3); + let (a, b, c) = (ids[0], ids[1], ids[2]); + for (id, error, warning) in [(a, None, Some("a")), (b, Some("b"), None), (c, None, Some("c"))] { + let result = h.app.session.model.results.iter_mut().find(|r| r.id == id).unwrap(); + result.error = error.map(str::to_string); + result.warning = warning.map(str::to_string); + } + let own = |h: &Harness, ids: &[u64]| { + let worst = ids.iter().filter_map(|id| super::feature_status(&h.app, *id).map(|s| (s, *id))).reduce(|a, b| if b.0 > a.0 { b } else { a }); + (worst.map(|w| w.0), worst.and_then(|w| super::feature_message(&h.app, w.1))) + }; + // Every folder short enough to be searched, then the same repeated past the length that + // builds the tables. + let check = |h: &Harness, folder: &[u64], status: Option, of: Option, why: &str| { + for ids in [folder.to_vec(), folder.repeat(16)] { + let found = super::worst_status(&h.app, &ids); + assert_eq!(found, (status, of.and_then(|id| super::feature_message(&h.app, id))), "{why}, {} ids", ids.len()); + assert_eq!(found, own(h, &ids), "{why}, {} ids", ids.len()); + } + }; + check(&h, &ids, Some(Status::Error), Some(b), "the error outweighs the warnings"); + check(&h, &[a, c], Some(Status::Warning), Some(a), "the first warned feature's message"); + check(&h, &[c, a], Some(Status::Warning), Some(c), "the first in the folder, not in the history"); + check(&h, &[], None, None, "an empty folder"); + check(&h, &[u64::MAX], None, None, "an id outside the history"); + + let mut twice = h.app.session.doc.features[0].clone(); + twice.suppressed = true; + std::sync::Arc::make_mut(&mut h.app.session.doc).features.push(twice); + check(&h, &[a], Some(Status::Warning), Some(a), "a feature listed twice is found at its first place"); + std::sync::Arc::make_mut(&mut h.app.session.doc).features.pop(); + + std::sync::Arc::make_mut(&mut h.app.session.doc).marker = Some(1); + check(&h, &ids, Some(Status::Warning), Some(a), "the features after the marker have no mark"); + std::sync::Arc::make_mut(&mut h.app.session.doc).marker = None; + std::sync::Arc::make_mut(&mut h.app.session.doc).features[1].suppressed = true; + check(&h, &ids, Some(Status::Warning), Some(a), "a suppressed feature has none"); + } + #[test] fn the_feature_menu_copies_a_warning_message() { for left in [false, true] { diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index 4d644f4768..c4b906f09d 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -568,10 +568,48 @@ fn vertical_marker(app: &mut SolveApp, ui: &mut egui::Ui, drag: &mut Option Option { +pub(crate) fn group_for(groups: &[solidcraft_engine::doc::TimelineGroup], feature: u64) -> Option { groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id) } +/// The features a drag starting on feature `id`'s row moves: the selection, with the row +/// selected first when it isn't. Shared with the History in the browser (#9565). +pub(crate) fn drag_features(app: &mut SolveApp, id: u64, selected: bool) -> Vec { + if !selected { + select_only(app, TimelineItem::Feature(id)); + } + selected_feature_ids(app) +} + +/// Drops dragged History items at timeline `position`, into folder `group` (`None`: the timeline +/// root), before the history marker when `active`. Shared with the History in the browser (#9565). +pub(crate) fn drop_items( + app: &mut SolveApp, + plan: &HistoryRows, + features: &[u64], + groups: &[u64], + group: Option, + position: usize, + active: bool, +) { + let mut params = json!({"group": group, "position": position}); + // A feature and the sketches listed under it move as one block (#6489); the move's own checks + // refuse a place where anything would fail. + let features = plan.with_listed(features); + if !features.is_empty() { + params["features"] = json!(features); + } + if !groups.is_empty() { + params["groups"] = json!(groups); + } + // Crossing the marker rolls the moved features back or forward, in the same step. + params["active"] = json!(active); + let _ = app.run("timeline.move_many", params); + if let Some(group) = group { + app.tree.collapsed.remove(&format!("timeline_group:{group}")); + } +} + fn selected_drag_items(app: &SolveApp, origin_x: f32, source_group: Option) -> DragItems { DragItems { features: selected_feature_ids(app), groups: app.tree.picked_timeline_groups.clone(), origin_x, source_group } } @@ -1383,22 +1421,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { 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 { - let mut params = json!({"group": folder_target, "position": slot.position}); - // A feature and the sketches listed under it move as one block (#6489); the - // move's own checks refuse a place where anything would fail. - let features = plan.with_listed(&items.features); - if !features.is_empty() { - params["features"] = json!(features); - } - if !items.groups.is_empty() { - params["groups"] = json!(items.groups); - } - // Crossing the marker rolls the moved features back or forward, in the same step. - params["active"] = json!(slot.y < marker_y); - let _ = app.run("timeline.move_many", params); - if let Some(group) = folder_target { - app.tree.collapsed.remove(&format!("timeline_group:{group}")); - } + drop_items(app, &plan, &items.features, &items.groups, folder_target, slot.position, slot.y < marker_y); } } } @@ -1497,11 +1520,9 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us rows.hovered = Some(id); } if response.drag_started() && !on_fold { - if !selected { - select_only(app, TimelineItem::Feature(id)); - } + let features = drag_features(app, id, selected); let origin_x = drag_press_x(&response, rect.center().x); - rows.drag = Some(Drag::Items(selected_drag_items(app, origin_x, group_id))); + 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 mut tooltip = egui::Tooltip::for_enabled(&response); diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 6bbce8f210..c0975fe61a 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -19,11 +19,20 @@ const ICON: f32 = 20.0; /// right of it, and so does the sketch tools' coordinate readout (`tools::preview`, drawn from /// [`READOUT`] below right). From the hot spot to the badge's bottom-left corner: const OFFSET: Vec2 = vec2(12.0, -8.0); -/// Where the sketch tools' coordinate readout starts, down and right of the hot spot. -const READOUT: f32 = 10.0; +/// Where the sketch tools' coordinate readout starts, down and right of the hot spot: right of +/// the system arrow, which hangs below it about 12 points wide at the default size and about 18 +/// at the next larger one, and would cover the first value (#9480). +pub(crate) const READOUT: Vec2 = vec2(26.0, 10.0); /// The gap to the hot spot when the badge flips to the left of the pointer. const FLIPPED_GAP: f32 = 6.0; +/// Where the coordinate readout of a sketch tool's point at `point` (screen) starts: [`READOUT`] +/// from the pointer while it is over `view`, else from the point. The point snaps away from the +/// pointer, so a readout placed from it could still slide under the arrow. +pub(crate) fn readout_at(painter: &egui::Painter, view: Rect, point: Pos2) -> Pos2 { + painter.ctx().pointer_hover_pos().filter(|p| view.contains(*p)).unwrap_or(point) + READOUT +} + /// The command whose tool or dialog is running: the one a click in the viewport goes to. pub(crate) fn command(app: &SolveApp) -> Option<&'static str> { match (&app.tool, &app.dialog) { @@ -50,7 +59,7 @@ fn badge_rect(at: Pos2, bounds: Rect, readout_height: f32) -> Rect { min.x = at.x - FLIPPED_GAP - BADGE; } if min.y < bounds.top() { - min.y = at.y + READOUT + readout_height + FLIPPED_GAP; + min.y = at.y + READOUT.y + readout_height + FLIPPED_GAP; } Rect::from_min_size(min, vec2(BADGE, BADGE)) } @@ -141,7 +150,7 @@ mod tests { assert_eq!(icon(&h.app), Some(icon_of("sketch.circle.center"))); let badge = badge.unwrap(); // The coordinate readout starts READOUT below and right of the pointer: no overlap. - assert!(badge.bottom() < at.y + READOUT, "the badge {badge:?} covers the readout under {at:?}"); + assert!(badge.bottom() < at.y + READOUT.y, "the badge {badge:?} covers the readout under {at:?}"); // Turned off in Navigation preferences. h.app.ui.tool_cursor = false; @@ -173,12 +182,35 @@ mod tests { for at in [middle, pos2(795.0, 300.0), pos2(400.0, 5.0), pos2(795.0, 5.0), pos2(5.0, 595.0), pos2(795.0, 595.0)] { let badge = badge_rect(at, bounds, line); assert!(bounds.contains_rect(badge), "at {at:?}: {badge:?} leaves {bounds:?}"); - let readout = Rect::from_min_size(at + vec2(READOUT, READOUT), vec2(120.0, line)); + let readout = Rect::from_min_size(at + READOUT, vec2(120.0, line)); assert!(!badge.intersects(readout), "at {at:?}: {badge:?} covers the readout {readout:?}"); assert!(!badge.contains(at), "at {at:?}: {badge:?} covers the hot spot"); } } + /// A line's coordinate readout starts right of the system arrow, which hangs below and + /// right of the hot spot about 12 × 19 points at the default size and 18 × 28 at the next + /// larger one, wherever the point snaps (#9480). + #[test] + fn the_coordinate_readout_starts_right_of_the_pointer_arrow() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.settle(); + h.step(&json!({"start": "sketch.line"})).unwrap(); + let c = h.app.viewport.rect.map(|r| r.center()).unwrap(); + // Off a grid point, so the point snaps away from the pointer. + for at in [c, c + vec2(5.0, 3.0), c + vec2(-4.0, 6.0)] { + h.step(&json!({"move": [at.x, at.y]})).unwrap(); + h.frames(2); + let readout = rect_of("viewport:readout").expect("the readout is drawn"); + let arrow = Rect::from_min_size(at, vec2(19.0, 29.0)); + assert!(!readout.intersects(arrow), "at {at:?}: the readout {readout:?} is under the arrow {arrow:?}"); + assert!(readout.top() > at.y, "at {at:?}: the readout {readout:?} is not below the hot spot"); + } + h.step(&json!({"key": "Escape"})).unwrap(); + } + /// Every interactive tool's command has an icon to show. #[test] fn every_interactive_tool_has_a_badge_icon() { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 239ab666f8..4ad86e43c7 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -986,11 +986,130 @@ fn ribbon_label_lines(painter: &egui::Painter, label: &str, width: f32) -> Vec bool { + PERSISTENT_COMMANDS.contains(&id) +} + +fn persistent_available(app: &SolveApp, id: &str) -> bool { + persistent_command(id) && (id != "inspect.section" || app.preferences.viewport_section_status) +} + +fn persistent_pressed(app: &SolveApp, id: &str) -> bool { + app.preferences.persistent_ribbon_pressed && persistent_available(app, id) && app.session.status_items().iter().any(|item| item.command == id) +} + +pub(crate) fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> String { + let info = crate::render_workspace::interactive_command_info(app, command); + let label = crate::workspace::ribbon_button_label(command); + app.tr_label(if label == command.label { info.label } else { label }).to_string() +} + +fn persistent_hint(app: &SolveApp, command: &solidcraft_engine::CommandSpec, enabled: bool) -> Option { + (enabled && persistent_pressed(app, command.id)).then(|| { + let name = persistent_button_name(app, command); + crate::i18n::Locale::for_language(&app.preferences.language).format("ui.viewport_status_clear_hint", &[("name", &name)]) + }) +} + +/// Paint slots retain original z-order, while their presentation is resolved after every +/// command interaction in this ribbon pass. These records never survive the current frame. +struct PersistentRibbonPaint { + command: &'static solidcraft_engine::CommandSpec, + response: egui::Response, + // Keep the allocating layer, clip and opacity when replacing these deferred slots. + painter: egui::Painter, + rect: Rect, + background: egui::layers::ShapeIdx, + label_slot: egui::layers::ShapeIdx, + neutral: egui::Shape, + label: Option, + enabled: bool, + disabled: Option, +} + +fn paint_persistent_ribbon(app: &SolveApp, ui: &egui::Ui, paints: Vec, tab: &str, namespace: &str) { + let t = Tokens::get(); + let mut count = 0; + for paint in paints { + let selected = persistent_pressed(app, paint.command.id); + let ink = if !paint.enabled { + t.text_dim + } else if selected { + t.selected_ink + } else { + t.text + }; + // Resolve the genuine live tool after dispatch too: a click may have ended it. + // The compatibility presentation keeps its original transient recipe. + let neutral = if app.preferences.persistent_ribbon_pressed && app.tool.as_ref().is_some_and(|tool| tool.cmd == paint.command.id) { + let accent = crate::theme::TabAccent::for_tab(&t, tab); + egui::Shape::Rect(egui::epaint::RectShape::new( + paint.rect, + t.radius_sm, + accent.soft, + Stroke::new(1.0, accent.color), + egui::StrokeKind::Inside, + )) + } else { + paint.neutral + }; + let background = if selected { + count += 1; + crate::scenario::publish_target(&format!("{namespace}:pressed:{}", paint.command.id), paint.rect); + ui_kit::persistent_button_shape(ui, &paint.response, paint.rect) + } else if paint.response.has_focus() && app.preferences.persistent_ribbon_pressed { + egui::Shape::Vec(vec![neutral, ui_kit::native_button_focus(ui, &paint.response, paint.rect)]) + } else { + neutral + }; + paint.painter.set(paint.background, background); + if paint.response.has_focus() { + crate::scenario::publish_target(&format!("{namespace}:focus:{}", paint.command.id), paint.rect); + } + if let Some(label) = paint.label { + let galley = ribbon_button_galley_weight(&paint.painter, &label, paint.rect.width() - BUTTON_LABEL_PADDING, ink, selected); + paint + .painter + .set(paint.label_slot, egui::Shape::galley(pos2(paint.rect.center().x, ribbon_label_top(paint.rect, galley.size().y)), galley, ink)); + } + paint.response.widget_info(|| { + if app.preferences.persistent_ribbon_pressed && persistent_available(app, paint.command.id) { + egui::WidgetInfo::selected(egui::WidgetType::Button, paint.enabled, selected, persistent_button_name(app, paint.command)) + } else { + let info = crate::render_workspace::interactive_command_info(app, paint.command); + egui::WidgetInfo::labeled(egui::WidgetType::Button, paint.enabled, app.tr_label(info.label)) + } + }); + let _ = ribbon_tooltip(app, paint.response, paint.command, paint.disabled.as_deref()); + } + crate::scenario::publish_count(&format!("{namespace}:pressed"), count as f64); +} + fn ribbon_button_galley(painter: &egui::Painter, label: &str, width: f32, color: Color32) -> std::sync::Arc { + ribbon_button_galley_weight(painter, label, width, color, false) +} + +fn ribbon_button_galley_weight(painter: &egui::Painter, label: &str, width: f32, color: Color32, selected: bool) -> std::sync::Arc { let lines = ribbon_button_lines(painter, label, width); // Explicit newlines only: egui must never split an oversized word. let size = crate::theme::Tokens::get().font_body; - let mut job = egui::text::LayoutJob::simple(lines.join("\n"), FontId::proportional(size), color, f32::INFINITY); + let mut job = egui::text::LayoutJob::simple( + lines.join("\n"), + if selected { crate::fonts::semibold(size) } else { FontId::proportional(size) }, + color, + f32::INFINITY, + ); job.halign = egui::Align::Center; // Two lines sit closer than the font's own spacing, so they fit in the button under its // icon (#8391). @@ -1347,6 +1466,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if reveal.is_some() { ui.ctx().request_repaint(); } + let mut persistent_paints = Vec::new(); let mut px = ribbon_left; let top = r.top() + TAB_ROW_HEIGHT; let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None; @@ -1362,7 +1482,15 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let lists: Vec<_> = crate::toolbar_custom::panel_lists(&tab) .into_iter() .filter(|(panel, _, _)| editing_in_place || !(tab == "MODEL" && *panel == "FINISH EDITING")) - .map(|(panel, cmds, promote)| { + .map(|(panel, mut cmds, promote)| { + let promote = if tab == "SKETCH" && panel == "CREATE" && !app.preferences.sketch_construction_lines { + promote.saturating_sub(1) + } else { + promote + }; + if !app.preferences.sketch_construction_lines { + cmds.retain(|c| solidcraft_engine::cmd::construction_line_mode(c.id).is_none()); + } let mut shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote); // Gallery cells are painted together after the large buttons, in reading order. if tab == "SKETCH" && panel == "CONSTRAIN" { @@ -1386,7 +1514,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { .map(|c| { let family = crate::sketch_families::family(c.id) .filter(|f| tab == "SKETCH" && *panel == "CREATE" && f.commands.first().copied() == Some(c.id)); - let label = family.map_or_else(|| crate::workspace::ribbon_button_label(c), |f| f.label); + let label = family.map_or_else(|| crate::workspace::ribbon_button_label(c), crate::workspace::ribbon_family_label); if labels { ButtonSize { width: ribbon_button_width(&painter, app.tr_label(label)), min_width: BUTTON_MIN_WIDTH } } else { @@ -1413,8 +1541,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let finish_plan = sizes.last().and_then(|size| if tab == "SKETCH" || (tab == "MODEL" && editing_in_place) { Some(Plan::full(size)) } else { None }); let finish_width = finish_plan.as_ref().map_or(0.0, |p| p.width().max(80.0)); + // The Finish group keeps its own gap before it, so the panels on its left give up room + // (collapse) before they would touch it. + let finish_gap = if finish_plan.is_some() { 4.0 } else { 0.0 }; let normal_count = sizes.len().saturating_sub(usize::from(finish_plan.is_some())); - let mut plans = fit(sizes.get(..normal_count).unwrap_or_default(), right - 4.0 - px - finish_width); + let mut plans = fit(sizes.get(..normal_count).unwrap_or_default(), right - 4.0 - px - finish_width - finish_gap); let finish_right = plans.iter().any(|p| p.fit == Fit::More); plans.extend(finish_plan); crate::scenario::publish_count("toolbar_collapsed", plans.iter().filter(|p| p.fit == Fit::Collapsed).count() as f64); @@ -1452,6 +1583,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let gallery: Vec<_> = shown.iter().copied().filter(|c| app.toolbar_custom.in_gallery(&tab, panel, c.id)).collect(); let large: Vec<_> = shown.iter().copied().filter(|c| !gallery.iter().any(|g| g.id == c.id)).collect(); let drawn = if collapsed { 0 } else { plan.shown.min(button_widths.len()) }; + // The commands the ribbon shows as buttons now (a narrow window shows fewer), which + // `ribbon_menus_skip_pinned` leaves out of the drop-down. + let on_ribbon: Vec<&str> = + large.iter().take(drawn).map(|c| c.id).chain(gallery.iter().filter(|_| !collapsed && drawn > large.len()).map(|c| c.id)).collect(); // Buttons narrower than the panel's minimum width (a one-button panel) are centred. let buttons_width = button_widths.iter().take(drawn).sum::() + drawn.saturating_sub(1) as f32 * BUTTON_GAP; let inset = if drawn == 0 { 0.0 } else { ((width - 8.0 - buttons_width) / 2.0).max(0.0).floor() }; @@ -1525,17 +1660,30 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.secondary_clicked() { menu = Some((resp.interact_pointer_pos().unwrap_or(br.left_bottom()), c.id)); } + let persistent = persistent_available(app, selected.id); + let background_slot = painter.add(egui::Shape::Noop); let accent = crate::theme::TabAccent::for_tab(&t, &tab); let family_open = family.is_some_and(|f| egui::Popup::is_id_open(ui.ctx(), ui.id().with(("family", f.label)).with("popup"))); - if !finish + let neutral = if !finish && (resp.is_pointer_button_down_on() - || running.as_deref() == Some(selected.id) + || (running.as_deref() == Some(selected.id) && !(persistent && app.preferences.persistent_ribbon_pressed)) || family_open || (selected.id == "view.shots_strip" && app.preferences.render_shots_strip)) { - painter.rect(whole, t.radius_sm, accent.soft, Stroke::new(1.0, accent.color), egui::StrokeKind::Inside); + egui::Shape::Rect(egui::epaint::RectShape::new( + whole, + t.radius_sm, + accent.soft, + Stroke::new(1.0, accent.color), + egui::StrokeKind::Inside, + )) } else if resp.hovered() && info.enabled { - painter.rect_filled(whole, t.radius_sm, t.hover); + egui::Shape::rect_filled(whole, t.radius_sm, t.hover) + } else { + egui::Shape::Noop + }; + if !persistent { + painter.set(background_slot, neutral.clone()); } let revealed = reveal.as_ref().filter(|r| r.id == c.id || r.id == selected.id || family.is_some_and(|f| f.commands.contains(&r.id))); if revealed.is_some() { @@ -1553,12 +1701,35 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { Rect::from_center_size(pos2(br.center().x, whole.top() + if labels { 16.0 } else { button_height / 2.0 }), vec2(side, side)); icons::paint_icon(&painter, icon_rect, icon, if info.enabled { 1.0 } else { 0.35 }); let tr_label = app.tr_label(info.label).to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &tr_label)); - if ribbon_tooltip(app, resp, selected, info.disabled_reason.as_deref()).clicked() && info.enabled { + if !persistent { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &tr_label)); + } + let clicked = resp.clicked(); + if !persistent { + let _ = ribbon_tooltip(app, resp.clone(), selected, info.disabled_reason.as_deref()); + } + if clicked && info.enabled { + if persistent && app.preferences.persistent_ribbon_pressed { + resp.request_focus(); + } let id = selected.id; app.start_pinned(id, Some(whole)); } - if let Some(f) = family { + if persistent { + let label = persistent_button_name(app, selected); + persistent_paints.push(PersistentRibbonPaint { + command: selected, + response: resp, + painter: painter.clone(), + rect: whole, + background: background_slot, + label_slot: painter.add(egui::Shape::Noop), + neutral, + label: labels.then_some(label), + enabled: ui.is_enabled() && info.enabled, + disabled: info.disabled_reason.clone(), + }); + } else if let Some(f) = family { let ar = if labels { Rect::from_min_max(pos2(whole.left(), whole.top() + 30.0), pos2(whole.right(), whole.top() + 48.0)) } else { @@ -1572,7 +1743,8 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { // The title sits on the same line as the other buttons' labels. if labels { let title_y = whole.top() + 39.0; - let galley = ribbon_button_galley(&painter, app.tr_label(f.label), ar.width() - 16.0, t.text); + let galley = + ribbon_button_galley(&painter, app.tr_label(crate::workspace::ribbon_family_label(f)), ar.width() - 16.0, t.text); painter.galley(pos2(ar.center().x - 4.0, title_y - galley.size().y / 2.0), galley, t.text); dropdown_arrow(&painter, pos2(ar.right() - 6.0, title_y + 0.5), t.text_dim); } else { @@ -1744,6 +1916,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let previewed = hidden_reveal.is_some_and(|r| r.preview && !open); if open || previewed { let cmds = if collapsed || trimmed { menu_commands(&cmds, &shown) } else { cmds.clone() }; + let cmds = if app.preferences.ribbon_menus_skip_pinned { skip_on_ribbon(cmds, &on_ribbon) } else { cmds }; let area = Container::new(ui.ctx(), Surface::Popup) .area(popup_id) .fixed_pos(lr.left_bottom()) @@ -1757,15 +1930,30 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } else if cmds.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr_label("Not available yet")).color(t.text_dim)); } + if sketch_create && app.preferences.ribbon_icon_grid && !cmds.is_empty() { + let mut paints = Vec::new(); + let action = dropdown_grid(app, ui, &tab, &sketch_create_grouped(&cmds), &[], &mut paints); + grid_action(app, ui, action, &mut menu, &mut toggle); + paint_persistent_ribbon(app, ui, paints, &tab, "ribbon_grid"); + return; + } if sketch_create { // One width for every row and the footer, as in the other panels. let row_w = dropdown_width(app, ui, &cmds); ui.set_width(row_w); sketch_create_menu(app, ui, &cmds, row_w, &mut menu, &mut toggle); - section_footer(ui); + section_footer(ui, "ui.ribbon_pin_footer"); return; } let grouped = crate::workspace::grouped_commands(&tab, panel, &cmds); + if app.preferences.ribbon_icon_grid && !grouped.is_empty() { + let mut paints = Vec::new(); + let action = dropdown_grid(app, ui, &tab, &grouped, &crate::workspace::ungrouped(&tab, panel, &cmds), &mut paints); + grid_action(app, ui, action, &mut menu, &mut toggle); + // Paint in this popup's layer, after its command has changed the session. + paint_persistent_ribbon(app, ui, paints, &tab, "ribbon_grid"); + return; + } let cmds: Vec<_> = grouped.iter().map(|(_, c)| *c).collect(); let row_w = dropdown_width(app, ui, &cmds); ui.set_width(row_w); @@ -1812,7 +2000,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } } }); - section_footer(ui); + section_footer(ui, "ui.ribbon_pin_footer"); }); }); popup_rect = Some(area.response.rect); @@ -1845,14 +2033,18 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } for c in cmds { let info = crate::render_workspace::interactive_command_info(app, c); - let response = crate::ui_kit::command_menu_button( - ui, - c.id, - info.label, - info.icon, - &crate::keymap::display_for(app, c.id).unwrap_or_default(), - info.enabled, - ); + let shortcut = crate::keymap::display_for(app, c.id).unwrap_or_default(); + let response = if persistent_available(app, c.id) && app.preferences.persistent_ribbon_pressed { + let mut item = ui_kit::MenuItem::new(c.id, info.label, info.icon); + item.shortcut = &shortcut; + item.enabled = info.enabled; + item.checked = Some(persistent_pressed(app, c.id)); + item.show(ui) + } else { + ui_kit::command_menu_button(ui, c.id, info.label, info.icon, &shortcut, info.enabled) + }; + let response = + if let Some(hint) = persistent_hint(app, c, info.enabled) { response.on_hover_text(hint) } else { response }; crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center()); if response.clicked() { app.start_at(c.id, Some(response.rect)); @@ -1867,6 +2059,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { }); } + // Resolve persistent paint only after large buttons, dropdowns and overflow have + // dispatched their actions. No already-painted old analysis can survive replacement. + paint_persistent_ribbon(app, ui, persistent_paints, &tab, "ribbon"); + // Dragging a command: its icon follows the pointer; a toolbar button dragged off the // toolbar (or into the open drop-down) shows it will be removed. let pointer = ui.input(|i| i.pointer.latest_pos()); @@ -1950,6 +2146,9 @@ const CONSTRAINT_GRID_WIDTH: f32 = 82.0; /// Registry parameter syntax is not a user-facing description. Reuse existing catalog prose /// where present; other commands intentionally show only their name and registered shortcut. fn ribbon_tooltip(app: &SolveApp, response: egui::Response, command: &solidcraft_engine::CommandSpec, disabled: Option<&str>) -> egui::Response { + if let Some(hint) = persistent_hint(app, command, disabled.is_none() && response.enabled() && command.info(&app.session).enabled) { + return response.on_hover_text(hint); + } response.on_hover_ui(|ui| { let t = Tokens::get(); let name = app.tr_label(crate::render_workspace::interactive_command_info(app, command).label); @@ -2044,26 +2243,31 @@ fn reveal_tooltip(app: &SolveApp, ui: &egui::Ui, rect: Rect, command: &solidcraf } } +/// A group heading inside a panel drop-down (Edges, Faces…; Line, Rectangle…), in the list and +/// the grid: a step below the drop-down's own header (`font_caption`) and written as named, not +/// in capitals, so it reads as part of the menu rather than above it. +fn group_heading_font() -> FontId { + crate::fonts::semibold(Tokens::get().font_micro) +} + +/// A group heading's text: its name in the interface language, as written. +fn group_heading_text(label: &str) -> String { + crate::i18n::tr_label(label).to_string() +} + fn section_heading(ui: &mut egui::Ui, label: &str, width: f32) { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); - ui.painter().text( - rect.left_center() + vec2(6.0, 0.0), - Align2::LEFT_CENTER, - crate::i18n::tr_label(label).to_uppercase(), - crate::fonts::semibold(crate::theme::Tokens::get().font_small), - t.text_dim, - ); + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, group_heading_text(label), group_heading_font(), t.text_dim); } -fn section_footer(ui: &mut egui::Ui) { +/// The note under a panel drop-down on how pinning works: `message` is its catalog id. +fn section_footer(ui: &mut egui::Ui, message: &str) { ui.separator(); ui.add_space(4.0); ui.add( - egui::Label::new( - egui::RichText::new(crate::i18n::tr("ui.ribbon_pin_footer")).size(crate::theme::Tokens::get().font_small).color(Tokens::get().text_dim), - ) - .wrap(), + egui::Label::new(egui::RichText::new(crate::i18n::tr(message)).size(crate::theme::Tokens::get().font_small).color(Tokens::get().text_dim)) + .wrap(), ); ui.add_space(4.0); } @@ -2124,6 +2328,9 @@ fn dropdown_row( let hot = info.enabled && (pointer_on || resp.has_focus()); let mut row = crate::ui_kit::MenuItem::new(c.id, &label, info.icon); row.enabled = info.enabled; + if persistent_available(app, c.id) && app.preferences.persistent_ribbon_pressed { + row.checked = Some(persistent_pressed(app, c.id)); + } if c.id == "view.shots_strip" { row.checked = Some(app.preferences.render_shots_strip); } @@ -2156,9 +2363,13 @@ fn dropdown_row( } // A disabled row says why; a documented command also shows its help (#2758). let reason = info.disabled_reason.as_deref().filter(|_| !info.enabled).map(|reason| app.tr_label(reason)); - let resp = match crate::command_help::row_tip(c.id, &label, Some(shortcut.as_str()).filter(|k| !k.is_empty()), reason) { - Some(tip) => crate::command_help::tooltip(resp, c.id, tip), - None => resp, + let resp = if let Some(hint) = persistent_hint(app, c, info.enabled) { + resp.on_hover_text(hint) + } else { + match crate::command_help::row_tip(c.id, &label, Some(shortcut.as_str()).filter(|k| !k.is_empty()), reason) { + Some(tip) => crate::command_help::tooltip(resp, c.id, tip), + None => resp, + } }; if resp.secondary_clicked() { return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom())); @@ -2169,6 +2380,390 @@ fn dropdown_row( RowAction::None } +/// Take up what was done in a grid drop-down, as for a list row: start the command (and close +/// the drop-down), open its menu, or pin or unpin it. +fn grid_action( + app: &mut SolveApp, + ui: &egui::Ui, + action: Option<(&'static str, RowAction)>, + menu: &mut Option<(egui::Pos2, &'static str)>, + toggle: &mut Option<&'static str>, +) { + match action { + Some((id, RowAction::Menu(at))) => *menu = Some((at, id)), + Some((id, RowAction::TogglePin)) => *toggle = Some(id), + Some((id, RowAction::Start(rect))) => { + app.start_at(id, Some(rect)); + ui.ctx().data_mut(|d| d.remove::(egui::Id::new("sc_panel_open"))); + } + Some((_, RowAction::None)) | None => {} + } +} + +/// Sketch › Create in the grid: each family of variants under its own heading (Line, Rectangle, +/// Circle…), then the other tools under "More". The list keeps its family sub-menus. +fn sketch_create_grouped(cmds: &[&'static solidcraft_engine::CommandSpec]) -> Vec<(Option<&'static str>, &'static solidcraft_engine::CommandSpec)> { + let mut out = Vec::new(); + for f in crate::sketch_families::FAMILIES { + let mut heading = Some(f.label); + for id in f.commands { + if let Some(c) = cmds.iter().find(|c| c.id == *id) { + out.push((heading.take(), *c)); + } + } + } + let mut heading = Some("More"); + for c in cmds.iter().filter(|c| crate::sketch_families::family(c.id).is_none()) { + out.push((heading.take(), *c)); + } + out +} + +/// A drop-down's commands without those the ribbon shows as buttons (`ribbon_menus_skip_pinned`), +/// unless that leaves none. +fn skip_on_ribbon(cmds: Vec<&'static solidcraft_engine::CommandSpec>, on_ribbon: &[&str]) -> Vec<&'static solidcraft_engine::CommandSpec> { + let rest: Vec<_> = cmds.iter().copied().filter(|c| !on_ribbon.contains(&c.id)).collect(); + if rest.is_empty() { cmds } else { rest } +} + +/// A grid drop-down's cell (#4739): the icon twice the size of a list row's, with room around it. +const GRID_CELL: f32 = 48.0; +const GRID_ICON: f32 = 32.0; +/// Columns a grid fills before a second row; a panel with fewer commands is as wide as it needs. +const GRID_COLUMNS: usize = 5; +/// Rows at most: a bigger menu grows to the right instead (unless the window is too narrow). +const GRID_ROWS: usize = 3; + +/// The height of a group's heading in a grid drop-down. +const GRID_HEADING: f32 = 24.0; +/// Icons side by side in a group's column, when groups stand side by side. +const GROUP_COLUMNS: usize = 2; + +/// Where a grid drop-down puts things, relative to its top left: each command's icon with its +/// (row, column) for the arrow keys, each group heading (where, how wide), the separators +/// between groups side by side (their x), and the whole size. +struct GridLayout { + cells: Vec<(Rect, (usize, usize))>, + headings: Vec<(egui::Pos2, f32, &'static str)>, + separators: Vec, + size: egui::Vec2, +} + +/// Lay out a grid drop-down, at most [`GRID_ROWS`] high, growing to the right. A menu of two or +/// more headed groups (Sketch › Create's families, Model › Modify's Edges, Faces…) puts the +/// groups side by side, each heading over a column of its icons, [`GROUP_COLUMNS`] across (more +/// for a group too big for three rows), when that fits in `room` (wide). Any other menu, or one +/// too wide for the window, fills rows: [`GRID_COLUMNS`] across, more when that would take over +/// three rows, fewer when the window is narrower; a heading starts a new row. `loose` names the +/// commands in no group (Model › Modify's Delete): they stand apart after the groups, without a +/// heading, so they stay put when a group is left out (`ribbon_menus_skip_pinned`). +fn grid_layout(grouped: &[(Option<&'static str>, &'static solidcraft_engine::CommandSpec)], loose: &[&str], room: f32) -> GridLayout { + let mut groups: Vec<(Option<&'static str>, Vec)> = Vec::new(); + for (i, (heading, c)) in grouped.iter().enumerate() { + let after_a_group = groups.last().is_some_and(|(h, _)| h.is_some()); + if heading.is_some() || groups.is_empty() || (after_a_group && loose.contains(&c.id)) { + groups.push((*heading, Vec::new())); + } + if let Some((_, items)) = groups.last_mut() { + items.push(i); + } + } + // A heading needs two icons' width; loose commands only as many columns as they fill. + let group_columns = |heading: Option<&str>, n: usize| n.div_ceil(GRID_ROWS).max(if heading.is_some() { GROUP_COLUMNS } else { 1 }); + let headed = groups.iter().filter(|(h, _)| h.is_some()).count(); + let side_columns: usize = groups.iter().map(|(h, items)| group_columns(*h, items.len())).sum(); + let side_by_side = headed >= 2 && side_columns as f32 * GRID_CELL <= room; + let mut cells = vec![(Rect::NOTHING, (0, 0)); grouped.len()]; + let mut headings = Vec::new(); + let mut separators = Vec::new(); + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(GRID_CELL, GRID_CELL)); + let size = if side_by_side { + let (mut first_column, mut rows) = (0, 0); + for (g, (heading, items)) in groups.iter().enumerate() { + let columns = group_columns(*heading, items.len()); + let x = first_column as f32 * GRID_CELL; + if g > 0 { + separators.push(x); + } + if let Some(h) = heading { + headings.push((pos2(x, 0.0), columns as f32 * GRID_CELL, *h)); + } + for (k, i) in items.iter().enumerate() { + let (row, column) = (k / columns, k % columns); + if let Some(slot) = cells.get_mut(*i) { + *slot = (cell(x + column as f32 * GRID_CELL, GRID_HEADING + row as f32 * GRID_CELL), (row, first_column + column)); + } + } + rows = rows.max(items.len().div_ceil(columns)); + first_column += columns; + } + vec2(first_column as f32 * GRID_CELL, GRID_HEADING + rows as f32 * GRID_CELL) + } else { + let n = grouped.len(); + let fit = ((room / GRID_CELL).floor() as usize).max(1); + let columns = n.div_ceil(GRID_ROWS).max(n.min(GRID_COLUMNS)).min(fit).max(1); + let (mut y, mut first_row) = (0.0, 0); + for (heading, items) in &groups { + if let Some(h) = heading { + headings.push((pos2(0.0, y), columns as f32 * GRID_CELL, *h)); + y += GRID_HEADING; + } + for (k, i) in items.iter().enumerate() { + let (row, column) = (k / columns, k % columns); + if let Some(slot) = cells.get_mut(*i) { + *slot = (cell(column as f32 * GRID_CELL, y + row as f32 * GRID_CELL), (first_row + row, column)); + } + } + let rows = items.len().div_ceil(columns); + y += rows as f32 * GRID_CELL; + first_row += rows; + } + vec2(columns as f32 * GRID_CELL, y) + }; + GridLayout { cells, headings, separators, size } +} + +/// An arrow key in a grid drop-down. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum GridKey { + Left, + Right, + Up, + Down, +} + +/// Where an arrow key moves the focus in a grid drop-down, as an index into `enabled` (the +/// enabled commands, as indices into `cells`): Left and Right step through them in order, Up and +/// Down to the nearest one in the row above or below, all wrapping. From none focused, Right +/// and Down go to the first, Left and Up to the last. +fn grid_step(cells: &[(usize, usize)], enabled: &[usize], at: Option, key: GridKey) -> Option { + let n = enabled.len(); + if n == 0 { + return None; + } + let Some(at) = at.filter(|at| *at < n) else { + return Some(if matches!(key, GridKey::Right | GridKey::Down) { 0 } else { n - 1 }); + }; + let cell = |k: usize| enabled.get(k).and_then(|i| cells.get(*i)).copied(); + match key { + GridKey::Right => Some((at + 1) % n), + GridKey::Left => Some((at + n - 1) % n), + GridKey::Up | GridKey::Down => { + let (row, column) = cell(at)?; + let rows: Vec = (0..n).filter_map(|k| cell(k).map(|c| c.0)).collect(); + let target = if key == GridKey::Down { + rows.iter().copied().filter(|r| *r > row).min().or_else(|| rows.iter().copied().min()) + } else { + rows.iter().copied().filter(|r| *r < row).max().or_else(|| rows.iter().copied().max()) + }?; + (0..n) + .filter_map(|k| cell(k).filter(|c| c.0 == target).map(|c| (k, c.1.abs_diff(column)))) + .min_by_key(|(_, distance)| *distance) + .map(|(k, _)| k) + } + } +} + +/// Arrow keys move the keyboard focus through a grid drop-down's enabled icons ([`grid_step`]). +fn grid_keys(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solidcraft_engine::CommandSpec], cells: &[(usize, usize)]) { + let key = ui.input(|i| { + [ + (egui::Key::ArrowLeft, GridKey::Left), + (egui::Key::ArrowRight, GridKey::Right), + (egui::Key::ArrowUp, GridKey::Up), + (egui::Key::ArrowDown, GridKey::Down), + ] + .into_iter() + .find(|(k, _)| i.key_pressed(*k)) + .map(|(_, key)| key) + }); + let Some(key) = key else { return }; + let enabled: Vec = cmds.iter().enumerate().filter(|(_, c)| c.info(&app.session).enabled).map(|(i, _)| i).collect(); + let focused = ui.ctx().memory(|m| m.focused()); + let at = enabled.iter().position(|i| cmds.get(*i).is_some_and(|c| focused == Some(dropdown_row_id(c.id)))); + if let Some(c) = grid_step(cells, &enabled, at, key).and_then(|k| enabled.get(k)).and_then(|i| cmds.get(*i)) { + ui.ctx().memory_mut(|m| { + m.request_focus(dropdown_row_id(c.id)); + m.move_focus(egui::FocusDirection::None); + }); + } +} + +/// A panel drop-down drawn as a grid of large icons (the `ribbon_icon_grid` preference, #4739): +/// the same commands, headings and actions as the list, the names in the tooltips. Returns the +/// command acted on, and how. +fn dropdown_grid( + app: &SolveApp, + ui: &mut egui::Ui, + tab: &str, + grouped: &[(Option<&'static str>, &'static solidcraft_engine::CommandSpec)], + loose: &[&str], + paints: &mut Vec, +) -> Option<(&'static str, RowAction)> { + // Wide enough for groups side by side, short of the window's edge. + let room = ui.ctx().content_rect().right() - ui.cursor().left() - 24.0; + let layout = grid_layout(grouped, loose, room); + let width = layout.size.x.max(200.0); + // A narrow grid sits in the middle of the drop-down's least width. + let inset = ((width - layout.size.x) / 2.0).max(0.0); + ui.set_width(width); + let cmds: Vec<_> = grouped.iter().map(|(_, c)| *c).collect(); + let keys: Vec<(usize, usize)> = layout.cells.iter().map(|(_, at)| *at).collect(); + grid_keys(app, ui, &cmds, &keys); + // Like the list, the grid scrolls when it would run past the window's bottom (#874). + let height = (ui.ctx().content_rect().bottom() - ui.cursor().top() - 64.0).max(GRID_CELL * 2.0); + let mut action = None; + egui::ScrollArea::vertical().id_salt("panel_menu_grid").max_height(height).min_scrolled_height(height).show(ui, |ui| { + let (area, _) = ui.allocate_exact_size(vec2(width, layout.size.y), Sense::hover()); + let origin = area.left_top() + vec2(inset, 0.0); + let t = Tokens::get(); + for (at, w, heading) in &layout.headings { + let text = group_heading_text(heading); + let clip = ui.clip_rect().intersect(Rect::from_min_size(origin + at.to_vec2(), vec2(*w, GRID_HEADING))); + let painter = ui.painter().with_clip_rect(clip); + // Centred over its group's icons. + painter.text(origin + at.to_vec2() + vec2(w / 2.0, GRID_HEADING / 2.0), Align2::CENTER_CENTER, text, group_heading_font(), t.text_dim); + } + // A line between groups side by side, as between the ribbon's sections. + for x in &layout.separators { + let x = origin.x + x; + ui.painter().line_segment([pos2(x, origin.y + 4.0), pos2(x, origin.y + layout.size.y - 4.0)], Stroke::new(1.0, t.border)); + } + for ((_, c), (rect, _)) in grouped.iter().zip(&layout.cells) { + let rect = rect.translate(origin.to_vec2()); + let pinned = crate::toolbar_custom::pinned_on(app, tab, c.id).is_some(); + match dropdown_cell(app, ui, c, rect, pinned, paints) { + RowAction::None => {} + done => action = Some((c.id, done)), + } + } + }); + action +} + +/// How long the pointer rests on a grid icon before its corner pin can be clicked (seconds). +const PIN_DELAY: f64 = 2.0; + +/// Whether the corner pin of grid icon `id` takes clicks: the pointer (`on`) has rested on the +/// icon for [`PIN_DELAY`]. One icon is timed at a time; the window redraws when the pin is due. +fn pin_armed(ui: &egui::Ui, id: &'static str, on: bool) -> bool { + let key = egui::Id::new("sc_grid_pin_hover"); + let now = ui.input(|i| i.time); + let since = ui.ctx().data_mut(|d| { + let rest = d.get_temp::<(&'static str, f64)>(key).filter(|(at, _)| *at == id); + match (on, rest) { + (true, Some((_, since))) => Some(since), + (true, None) => { + d.insert_temp(key, (id, now)); + Some(now) + } + (false, Some(_)) => { + d.remove::<(&'static str, f64)>(key); + None + } + (false, None) => None, + } + }); + let Some(since) = since else { return false }; + let left = PIN_DELAY - (now - since); + if left > 0.0 { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + left <= 0.0 +} + +/// One icon of a grid drop-down: clicked, dragged onto the ribbon (to pin it) or right-clicked +/// like a list row; its tooltip is a ribbon button's (name, shortcut, help, why it is off), and a +/// pin in its corner says it is on the ribbon. +fn dropdown_cell( + app: &SolveApp, + ui: &mut egui::Ui, + c: &'static solidcraft_engine::CommandSpec, + rect: Rect, + pinned: bool, + paints: &mut Vec, +) -> RowAction { + let t = Tokens::get(); + let info = c.info(&app.session); + let sense = if info.enabled { Sense::click_and_drag() } else { Sense::drag() }; + let resp = ui.interact(rect, dropdown_row_id(c.id), sense); + if resp.has_focus() && !ui.clip_rect().contains_rect(rect) { + resp.scroll_to_me_animation(None, egui::style::ScrollAnimation::none()); + } + if resp.drag_started() { + resp.dnd_set_drag_payload(ToolbarDrag { id: c.id, from_menu: true }); + } + // The pin in the corner, as at a list row's end. It takes clicks (above the cell) only once + // the pointer has rested on the icon for PIN_DELAY: until then a click anywhere starts the + // command, so a quick click near the corner never pins or unpins it by accident. + let pr = Rect::from_min_size(pos2(rect.right() - 15.0, rect.top()), vec2(15.0, 15.0)); + let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); + let armed = pin_armed(ui, c.id, ui.rect_contains_pointer(rect) && !dragging); + let pin = armed.then(|| ui.interact(pr, ui.id().with(("dropdown_pin", c.id)), Sense::click())); + let pin_hovered = pin.as_ref().is_some_and(egui::Response::hovered); + let persistent = app.preferences.persistent_ribbon_pressed && persistent_available(app, c.id); + if !persistent { + let label = app.tr_label(c.label).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label)); + } + crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center()); + crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center()); + let pointer_on = (resp.hovered() || pin_hovered) && !dragging; + let hot = info.enabled && (pointer_on || resp.has_focus()); + let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)); + let cell = rect.shrink(2.0); + let mut neutral = Vec::new(); + if hot { + neutral.push(egui::Shape::rect_filled(cell, t.control_radius, t.accent_soft)); + crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center()); + } + if resp.has_focus() && !persistent { + neutral.push(egui::Shape::rect_stroke(cell, t.control_radius, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside)); + } + let neutral = egui::Shape::Vec(neutral); + if persistent { + // Reuse the native ribbon recipe; only responses/paint slots survive until dispatch. + paints.push(PersistentRibbonPaint { + command: c, + response: resp.clone(), + painter: painter.clone(), + rect: cell, + background: painter.add(egui::Shape::Noop), + label_slot: painter.add(egui::Shape::Noop), + neutral, + label: None, + enabled: ui.is_enabled() && info.enabled, + disabled: info.disabled_reason.clone(), + }); + } else { + painter.add(neutral); + } + icons::paint_icon(&painter, Rect::from_center_size(rect.center(), vec2(GRID_ICON, GRID_ICON)), c.icon, if info.enabled { 1.0 } else { 0.4 }); + // A pinned command always shows its pin, clear of the icon's own artwork; any other shows one + // to pin it once it can be clicked. + if pinned || armed { + if pin_hovered { + painter.rect_filled(pr, 3.0, t.border.gamma_multiply(0.6)); + } + pin_icon(&painter, pr.shrink(2.5), pinned, if pinned { t.accent } else { t.text_dim }); + } + if let Some(pin) = pin { + crate::scenario::publish_handle(&format!("dropdown_pin:{}", c.id), pr.center()); + let tip = if pinned { "Remove from Toolbar" } else { "Pin to Toolbar" }; + if pin.on_hover_text(crate::i18n::tr_label(tip)).clicked() { + return RowAction::TogglePin; + } + } + let resp = if persistent { resp } else { ribbon_tooltip(app, resp, c, info.disabled_reason.as_deref()) }; + if resp.secondary_clicked() { + return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom())); + } + if resp.clicked() && info.enabled { + return RowAction::Start(rect); + } + RowAction::None +} + /// Creation menus keep related variants together, including collapsed and overflow panels. /// The plain rows are `width` wide, the width the menu gives its family rows. fn sketch_create_menu( @@ -2286,8 +2881,14 @@ fn drag_ghost(app: &SolveApp, ctx: &egui::Context, id: &str, at: egui::Pos2, rem pub(crate) fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); ui.label(egui::RichText::new(crate::i18n::tr_label("Selection filter")).color(t.text_dim).size(t.font_small)); - if crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Select bodies (a face picks its body)"), &mut app.ui.pick_bodies).changed() { - app.ui.pick_components = false; + for (mode, label) in [("body", app.tr_label("Select bodies (a face picks its body)")), ("component", app.tr("ui.select_components_by_face"))] { + let mut on = app.ui.pick_mode() == mode; + if ui_kit::checkbox(ui, label, &mut on).changed() { + // The Faces checkbox below is independent. The standalone command enables faces. + let filters = std::mem::take(&mut app.ui.pick_off); + let _ = app.run("select.filter", json!({"mode": if on { mode } else { "face" }})); + app.ui.pick_off = filters; + } } for (k, label) in [ ("faces", "Faces"), @@ -2314,6 +2915,7 @@ pub const ESCAPE_TWICE_S: f64 = 0.4; /// Global keyboard shortcuts (when no text field has focus). pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { + crate::viewport_status::reconcile(app, ctx); crate::browser::update_folder_pick(app, ctx); if design_shown(app) { crate::navigation::step_key_hold(app, ctx); @@ -2323,6 +2925,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { if app.file_load.busy() { return; } + if crate::viewport_status::enabled(app) + && (app.esc_handled || app.escape_owner.is_some()) + && ctx.input(|input| input.key_pressed(egui::Key::Escape)) + { + return; + } // File is a custom Area, so it must claim Escape before the underlying editors and // sketch shortcuts. Clear raw events too: egui may replay them on a layout pass. let file_menu_id = egui::Id::new("sc_file_menu"); @@ -2348,17 +2956,41 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } return; } + // These owners render after global shortcuts. Leave their Escape available to the + // normal window/palette handler, but retain ownership if egui repeats the layout. + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) + && (app.prefs_window.open || app.keymap.open || app.ui.palette_open || ctx.memory(|memory| memory.top_modal_layer().is_some())) + { + app.escape_owner = Some(crate::EscapeOwner::Overlay); + if crate::viewport_status::enabled(app) { + app.last_escape = None; + return; + } + } if crate::params_dialog::cancel_on_escape(app, ctx) { return; } // A dimension value that is taking the keyboard: letters belong to it (a parameter name // starting with L or D), even on the frame before its field holds the focus. - if egui::Popup::is_any_open(ctx) - || ctx.egui_wants_keyboard_input() - || crate::dim_view::editing() + let popup_owner = egui::Popup::is_any_open(ctx) || crate::dialogs_save_mesh::popup_open(ctx) || 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 + || inline_editor + || (crate::viewport_status::enabled(app) && app.ui.palette_open) + || (ctx.egui_wants_keyboard_input() && !(crate::viewport_status::enabled(app) && app.viewport_status.owns_focus(ctx))) { + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + let owner = if app.dialog.is_some() && !popup_owner && !inline_editor { crate::EscapeOwner::Dialog } else { crate::EscapeOwner::Overlay }; + // Reserve first refusal without claiming that cancellation already occurred. + // A dialog's own late handler must still be able to cancel its focused field. + app.escape_owner.get_or_insert(owner); + if crate::viewport_status::enabled(app) { + app.last_escape = None; + } + } return; } let (keys, mods) = ctx.input(|i| { @@ -2446,6 +3078,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { // Not while a tool is still out (its first Esc only ended a chain): this press // puts it down, and the next quick one finishes. if again + && !(crate::viewport_status::enabled(app) && (app.viewport_status.open_key.is_some() || crate::viewport_status::has_effect(app))) && app.tool.is_none() && app.dialog.is_none() && let Some(sketch) = app.session.active_sketch @@ -2470,6 +3103,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { crate::tools::finish(app); } else 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; } else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() { crate::edit_clipboard::drop_cut(app); app.tree.picked_joints.clear(); @@ -2759,11 +3394,12 @@ mod header_report_tests { FileAction::Quit.run(&mut h.app); assert!(h.app.docs.quitting.is_none() && h.app.docs.closing.is_none()); // Help's windows wait too; Report an Issue… shows the report. - for item in ["about", "shortcuts", "messages", "crashes", "connect"] { + for item in ["about", "shortcuts", "messages", "crashes", "connect", "updates"] { assert!(!crate::help::available(&h.app, item), "{item}"); crate::help::run(&mut h.app, &h.ctx, item); } assert!(!h.app.help.about && !h.app.keymap.open && !h.app.messages.open && h.app.help.connect.is_none()); + assert!(!h.app.updater.open); assert!(crate::help::available(&h.app, "report") && crate::help::available(&h.app, "docs")); h.app.issue_report.draft = None; assert!(FileAction::Undo.enabled(&h.app) && FileAction::Settings.enabled(&h.app) && FileAction::Quit.enabled(&h.app)); @@ -3490,6 +4126,341 @@ mod dropdown_tests { assert!(menu.contains(*at), "{id} at {at:?} is cut off by the drop-down {menu:?}"); } } + + /// The grid's rows: a heading starts a new one, and the arrow keys move along and between + /// them over the enabled icons only, wrapping at the ends (#4739). + #[test] + fn grid_rows_break_at_headings_and_arrow_keys_skip_disabled_icons() { + let c = |id| solidcraft_engine::find_command(id).unwrap(); + let grouped = [ + (Some("Primitives"), c("solid.box")), + (None, c("solid.cylinder")), + (None, c("solid.sphere")), + (None, c("solid.torus")), + (None, c("solid.pipe")), + (Some("Other"), c("solid.extrude")), + (None, c("solid.revolve")), + ]; + // Too narrow for the groups side by side (two icons wide): rows as wide as the window, a + // heading starting a row. + let rows = grid_layout(&grouped, &[], 100.0); + let at = |l: &GridLayout| l.cells.iter().map(|(_, at)| *at).collect::>(); + assert_eq!(at(&rows), [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (3, 0), (3, 1)]); + assert_eq!(rows.headings.len(), 2); + // Room enough: each group a column of two under its heading, the groups side by side. + let side = grid_layout(&grouped, &[], 1000.0); + assert_eq!(at(&side), [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (0, 2), (0, 3)]); + let headings: Vec<_> = side.headings.iter().map(|(p, _, h)| (p.x, *h)).collect(); + assert_eq!(headings, [(0.0, "Primitives"), (2.0 * GRID_CELL, "Other")]); + assert_eq!(side.size, vec2(4.0 * GRID_CELL, GRID_HEADING + 3.0 * GRID_CELL)); + assert_eq!(side.separators, [2.0 * GRID_CELL], "a line between the two groups"); + assert!(rows.separators.is_empty(), "rows have none"); + assert!(side.cells.iter().all(|(r, _)| r.top() >= GRID_HEADING), "icons below the headings"); + // A menu without groups keeps its rows of five. + let plain: Vec<_> = grouped.iter().map(|(_, c)| (None, *c)).collect(); + assert_eq!(at(&grid_layout(&plain, &[], 1000.0)), [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (1, 0), (1, 1)]); + let cells = at(&side); + // `solid.pipe` (index 4) is disabled: `enabled` holds the others. + let enabled = [0, 1, 2, 3, 5, 6]; + let step = |at, key| grid_step(&cells, &enabled, at, key).map(|k: usize| enabled[k]); + assert_eq!((step(None, GridKey::Right), step(None, GridKey::Down)), (Some(0), Some(0))); + assert_eq!((step(None, GridKey::Left), step(None, GridKey::Up)), (Some(6), Some(6))); + assert_eq!(step(Some(2), GridKey::Right), Some(3), "Right runs on into the next row"); + assert_eq!(step(Some(0), GridKey::Left), Some(6), "and wraps"); + assert_eq!(step(Some(1), GridKey::Down), Some(3), "Down goes to the nearest enabled icon below"); + assert_eq!(step(Some(4), GridKey::Down), Some(3), "from the next group's column too"); + assert_eq!(step(Some(2), GridKey::Down), Some(0), "from the last enabled row Down wraps to the first"); + assert_eq!(step(Some(5), GridKey::Up), Some(3), "from the first row Up wraps to the last, nearest column"); + assert_eq!(step(Some(1), GridKey::Up), Some(3)); + assert_eq!(grid_step(&cells, &[], None, GridKey::Down), None); + } + + /// A command in no group (Model › Modify's Delete) stands apart after the groups, without a + /// heading: leaving out a pinned group (Specialise's Convert to Sheet Metal) must not move it + /// under the group before (Bodies) (#4739). + #[test] + fn a_command_in_no_group_stays_apart_when_a_group_is_left_out() { + let specs = solidcraft_engine::command_specs(); + let (cmds, _) = crate::workspace::layout("MODEL", "MODIFY", &specs).unwrap(); + let loose = crate::workspace::ungrouped("MODEL", "MODIFY", &cmds); + assert_eq!(loose, ["timeline.delete"]); + let column_of = |cmds: &[&'static solidcraft_engine::CommandSpec], id: &str| { + let grouped = crate::workspace::grouped_commands("MODEL", "MODIFY", cmds); + let layout = grid_layout(&grouped, &loose, 4000.0); + let x = |id: &str| grouped.iter().position(|(_, c)| c.id == id).and_then(|i| layout.cells.get(i)).map(|(r, _)| r.left()); + let last_group = layout.separators.last().copied(); + (x(id), last_group) + }; + let (delete, separator) = column_of(&cmds, "timeline.delete"); + assert_eq!(delete, separator, "Delete in a column of its own after Specialise"); + let skipped = skip_on_ribbon(cmds.clone(), &["sheet.convert"]); + let (delete, separator) = column_of(&skipped, "timeline.delete"); + let (remove, _) = column_of(&skipped, "solid.remove"); + assert_eq!(delete, separator, "still apart without Specialise"); + assert!(delete > remove, "not under Bodies: {delete:?} vs {remove:?}"); + } + + /// Group headings sit below the drop-down's own header in the hierarchy: a smaller font than + /// the header's, and written as named rather than in capitals (list and grid alike). + #[test] + fn group_headings_are_smaller_than_the_drop_down_header() { + let t = Tokens::get(); + assert!(group_heading_font().size < t.font_caption, "{} vs the header's {}", group_heading_font().size, t.font_caption); + assert_eq!(group_heading_text("Edges"), "Edges", "written as named, not in capitals"); + } + + /// Every panel's drop-down of every tab, and Sketch › Create's families, is at most + /// GRID_ROWS high in the grid when the window is wide enough: big menus grow to the right. + #[test] + fn every_grid_menu_is_at_most_three_rows_high() { + let rows = |l: &GridLayout| l.cells.iter().map(|(r, _)| r.top().round() as i32).collect::>().len(); + let tabs: std::collections::BTreeSet<&str> = crate::workspace::SECTIONS.iter().map(|s| s.tab).collect(); + for tab in tabs { + for (panel, cmds, _) in crate::toolbar_custom::panel_lists(tab) { + let grouped = crate::workspace::grouped_commands(tab, panel, &cmds); + let layout = grid_layout(&grouped, &crate::workspace::ungrouped(tab, panel, &cmds), 4000.0); + assert!(rows(&layout) <= GRID_ROWS, "{tab} {panel}: {} rows", rows(&layout)); + if tab == "SKETCH" && panel == "CREATE" { + let families = grid_layout(&sketch_create_grouped(&cmds), &[], 4000.0); + assert!(rows(&families) <= GRID_ROWS, "Sketch › Create families: {} rows", rows(&families)); + } + } + } + // A window too narrow for the width wraps into more rows instead of running off it. + let c = |id| solidcraft_engine::find_command(id).unwrap(); + let many: Vec<_> = solidcraft_engine::command_specs().into_iter().take(30).map(|c| (None, c)).collect(); + let narrow = grid_layout(&many, &[], 4.0 * GRID_CELL); + assert!(narrow.size.x <= 4.0 * GRID_CELL && rows(&narrow) > GRID_ROWS); + let few = [(None, c("solid.box")), (None, c("solid.sphere"))]; + assert_eq!(grid_layout(&few, &[], 4000.0).size, vec2(2.0 * GRID_CELL, GRID_CELL), "a small menu is one row"); + } + + /// With `ribbon_icon_grid` on, a panel's drop-down shows the same commands as its list, as + /// large icons in rows of at most five; arrow keys move across and down; a click starts the + /// command, and a drag pins it, as from the list (#4739). + #[test] + fn the_icon_grid_shows_the_lists_commands_and_acts_like_it() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1000})); + let open = |h: &mut crate::scenario::Harness| { + h.frames(4); + h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap(); + h.frames(6); + let menu = crate::scenario::rect_of("toolbar_dropdown").expect("the drop-down is open"); + (menu, crate::scenario::handles("dropdown:")) + }; + let (_, list) = open(&mut h); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(!h.app.preferences.ribbon_icon_grid, "the list stays the default"); + h.call("ui.prefs", json!({"ribbon_icon_grid": true})); + assert!(h.app.preferences.ribbon_icon_grid); + let (menu, grid) = open(&mut h); + let ids = |rows: &[(String, egui::Pos2)]| rows.iter().map(|(id, _)| id.clone()).collect::>(); + assert_eq!(ids(&grid), ids(&list), "the grid shows the list's commands in its order"); + assert!(grid.len() > GRID_COLUMNS * GRID_ROWS, "MODEL CREATE is a big menu: {grid:?}"); + for (id, at) in &grid { + assert!(menu.contains(*at), "{id} at {at:?} is outside the drop-down {menu:?}"); + } + let ys: std::collections::BTreeSet = grid.iter().map(|(_, at)| at.y.round() as i32).collect(); + assert_eq!(ys.len(), GRID_ROWS, "laid out in {GRID_ROWS} rows, growing to the right: {ys:?}"); + let columns = grid.len().div_ceil(GRID_ROWS); + assert!(menu.width() <= columns as f32 * GRID_CELL + 40.0, "no wider than its columns: {menu:?}"); + + let hot = |h: &mut crate::scenario::Harness, key: &str| { + h.step(&json!({"key": key})).unwrap(); + h.frames(3); + let hot = crate::scenario::handles("dropdown_hot:"); + let [(_, at)] = hot.as_slice() else { panic!("one highlighted icon after {key}: {hot:?}") }; + *at + }; + let first = hot(&mut h, "ArrowRight"); + let second = hot(&mut h, "ArrowRight"); + assert!(second.x > first.x && (second.y - first.y).abs() < 1.0, "Right moves along the row: {first:?} → {second:?}"); + let below = hot(&mut h, "ArrowDown"); + assert!(below.y > second.y, "Down moves to the row below: {second:?} → {below:?}"); + + h.step(&json!({"click":{"handle":"dropdown:solid.box"}})).unwrap(); + h.frames(3); + assert!(crate::scenario::rect_of("toolbar_dropdown").is_none(), "a click closes the drop-down"); + assert!(h.app.dialog.is_some() || h.app.tool.is_some(), "and starts the command"); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(3); + + open(&mut h); + let label = crate::scenario::handle_at("panel:MODEL:CREATE").unwrap(); + h.step(&json!({"drag":[{"handle":"dropdown:solid.cylinder"}, [label.x, label.y]],"steps":12})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.cylinder").is_some(), "dragged onto the ribbon, it is pinned"); + assert!(h.app.session.doc.features.is_empty(), "pinning does not run the command"); + } + + /// A right-click on a drop-down's command, in the list and in the grid, opens its menu: + /// Pin to Toolbar, Remove from Toolbar and Pin to Shortcuts act on that command. + #[test] + fn a_right_click_in_the_drop_down_pins_and_unpins() { + for grid in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1000})); + h.app.preferences.ribbon_icon_grid = grid; + let pick = |h: &mut crate::scenario::Harness, item: &str| { + h.frames(4); + h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap(); + h.frames(4); + h.step(&json!({"click":{"handle":"dropdown:solid.box"},"button":"right"})).unwrap(); + h.frames(4); + assert!(h.app.viewport.context_menu.is_some(), "grid {grid}: the right-click opens the command's menu"); + h.step(&json!({"widget": item})).unwrap_or_else(|e| panic!("grid {grid}: {item}: {e}")); + h.frames(4); + }; + pick(&mut h, "Pin to Toolbar"); + assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_some(), "grid {grid}: pinned"); + pick(&mut h, "Remove from Toolbar"); + assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_none(), "grid {grid}: unpinned"); + pick(&mut h, "Pin to Shortcuts"); + assert!(h.app.sbox.pinned.iter().any(|p| p == "solid.box"), "grid {grid}: a shortcut"); + // The pin shown on the pointed-at row or icon pins it, and again unpins it. A grid + // icon's pin takes clicks only after the pointer has rested on it for PIN_DELAY. + for pinned in [true, false] { + h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap(); + h.frames(4); + h.step(&json!({"move":{"handle":"dropdown:solid.box"}})).unwrap(); + h.frames(3); + if grid { + assert!(crate::scenario::handle_at("dropdown_pin:solid.box").is_none(), "no pin to click straight away"); + h.frames((PIN_DELAY * 60.0) as usize + 5); + } + h.step(&json!({"click":{"handle":"dropdown_pin:solid.box"}})).unwrap(); + h.frames(3); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_some(), pinned, "grid {grid}: the hover pin"); + } + assert!(h.app.session.doc.features.is_empty(), "grid {grid}: pinning does not run the command"); + } + } + + /// A quick click on a grid icon's corner, where its pin appears after PIN_DELAY, starts the + /// command instead of pinning it. + #[test] + fn a_quick_click_on_the_pin_corner_starts_the_command() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1000})); + h.app.preferences.ribbon_icon_grid = true; + h.frames(4); + h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap(); + h.frames(4); + let at = crate::scenario::handle_at("dropdown:solid.box").unwrap() + vec2(GRID_CELL / 2.0 - 7.0, -GRID_CELL / 2.0 + 7.0); + h.step(&json!({"click":[at.x, at.y]})).unwrap(); + h.frames(3); + assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_none(), "not pinned"); + assert!(h.app.dialog.is_some() || h.app.tool.is_some(), "the command starts"); + } + + /// A command pinned from the grid becomes a ribbon button; Remove from Toolbar in the + /// button's own right-click menu takes it off again, with both options on. + #[test] + fn a_pinned_button_is_removed_from_its_own_menu() { + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + a_pinned_button_is_removed_in(mode); + } + } + + fn a_pinned_button_is_removed_in(mode: crate::shell::LayoutMode) { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.set", json!({"layout":mode})); + h.call("ui.resize", json!({"width":1600,"height":1000})); + h.app.preferences.ribbon_icon_grid = true; + h.app.preferences.ribbon_menus_skip_pinned = true; + h.frames(4); + h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap(); + h.frames(4); + h.step(&json!({"move":{"handle":"dropdown:solid.loft"}})).unwrap(); + h.frames((PIN_DELAY * 60.0) as usize + 5); + h.step(&json!({"click":{"handle":"dropdown_pin:solid.loft"}})).unwrap(); + h.frames(3); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(4); + assert!(crate::scenario::handle_at("button:solid.loft").is_some(), "pinned, Loft is a ribbon button"); + h.step(&json!({"click":{"handle":"button:solid.loft"},"button":"right"})).unwrap(); + h.frames(4); + assert!(h.app.viewport.context_menu.is_some(), "the button's menu opens"); + h.step(&json!({"widget":"Remove from Toolbar"})).unwrap(); + h.frames(4); + assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.loft").is_none(), "removed: {:?}", h.app.toolbar_custom.panels); + assert!(crate::scenario::handle_at("button:solid.loft").is_none(), "and gone from the ribbon"); + } + + /// Sketch › Create in the grid shows every variant of each family (Line, Rectangle, …) under + /// the family's heading, then the other tools, as large icons; a click starts the tool. + #[test] + fn sketch_create_in_the_grid_shows_every_variant() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1000})); + h.app.preferences.ribbon_icon_grid = true; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.frames(4); + h.step(&json!({"click":{"handle":"panel:SKETCH:CREATE"}})).unwrap(); + h.frames(4); + let cells: Vec = crate::scenario::handles("dropdown:").into_iter().map(|(id, _)| id).collect(); + for id in ["sketch.line", "sketch.rectangle.center", "sketch.circle.three_point", "sketch.arc.tangent", "sketch.polygon.edge", "sketch.point"] + { + assert!(cells.iter().any(|c| *c == format!("dropdown:{id}")), "{id} is an icon of the grid: {cells:?}"); + } + let grouped = sketch_create_grouped( + &solidcraft_engine::command_specs().into_iter().filter(|c| c.id.starts_with("sketch.rectangle")).collect::>(), + ); + assert_eq!(grouped.first().and_then(|(h, _)| *h), Some("Rectangle")); + assert!(grouped.iter().skip(1).all(|(h, _)| h.is_none()), "one heading per family"); + h.step(&json!({"click":{"handle":"dropdown:sketch.rectangle.center"}})).unwrap(); + h.frames(3); + assert!(crate::scenario::rect_of("toolbar_dropdown").is_none(), "a click closes the drop-down"); + assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.rectangle.center"), "and starts the tool"); + } + + /// With `ribbon_menus_skip_pinned`, a drop-down leaves out the commands the ribbon shows as + /// buttons, in the list and in the grid; a panel too narrow for its buttons lists them again. + #[test] + fn pinned_commands_can_be_left_out_of_the_drop_down() { + for grid in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1000})); + h.app.preferences.ribbon_icon_grid = grid; + let rows = |h: &mut crate::scenario::Harness, panel: &str| { + h.frames(4); + h.step(&json!({"click":{"handle":panel}})).unwrap(); + h.frames(4); + let rows: Vec = crate::scenario::handles("dropdown:").into_iter().map(|(id, _)| id).collect(); + h.step(&json!({"key":"Escape"})).unwrap(); + rows + }; + let all = rows(&mut h, "panel:MODEL:CREATE"); + assert!(all.iter().any(|id| id == "dropdown:solid.extrude"), "grid {grid}: listed by default: {all:?}"); + h.call("ui.prefs", json!({"ribbon_menus_skip_pinned": true})); + let rest = rows(&mut h, "panel:MODEL:CREATE"); + for pinned in ["solid.extrude", "solid.revolve", "solid.hole"] { + assert!(!rest.iter().any(|id| *id == format!("dropdown:{pinned}")), "grid {grid}: {pinned} is on the ribbon: {rest:?}"); + } + assert_eq!(rest.len() + 3, all.len(), "grid {grid}: only the ribbon's buttons are left out"); + // In a window too narrow for the Extrude button, MODEL CREATE's menu lists it again. + let narrow = (3..=16).rev().map(|w| w as f32 * 100.0).find(|w| { + h.call("ui.resize", json!({"width": w, "height": 1000})); + h.frames(4); + crate::scenario::handle_at("button:solid.extrude").is_none() + }); + assert!(narrow.is_some(), "grid {grid}: a narrow window has no room for the Extrude button"); + let panel = ["panel:MODEL:CREATE", "collapsed:MODEL:CREATE"].into_iter().find(|p| crate::scenario::handle_at(p).is_some()); + let Some(panel) = panel else { panic!("grid {grid}: MODEL CREATE is in the » menu at {narrow:?}") }; + let folded = rows(&mut h, panel); + assert!(folded.iter().any(|id| id == "dropdown:solid.extrude"), "grid {grid}: Extrude, off the ribbon, is listed: {folded:?}"); + } + let box_ = solidcraft_engine::find_command("solid.box").unwrap(); + assert_eq!(skip_on_ribbon(vec![box_], &["solid.box"]).len(), 1, "a drop-down is never left empty"); + } } #[cfg(test)] @@ -4158,3 +5129,646 @@ mod sketch_return_tab_tests { assert_eq!(h.app.ui.tab, "MODEL", "{start}: falls back to MODEL"); } } + +#[cfg(test)] +mod persistent_ribbon_tests { + use super::*; + use crate::scenario::{self, Harness}; + + fn harness() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":2400,"height":1000})); + h.app.session.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.ui.tab = "INSPECT".into(); + for id in PERSISTENT_COMMANDS { + crate::toolbar_custom::pin(&mut h.app, "INSPECT", id).unwrap(); + } + h.frames(3); + h + } + + fn shapes(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(v) => { + for s in v { + shapes(s, out); + } + } + s => out.push(s.clone()), + } + } + + fn button_shapes(h: &Harness, id: &str) -> Vec { + let r = scenario::rect_of(&format!("button:{id}")).unwrap(); + let mut all = Vec::new(); + for s in &h.shapes { + shapes(&s.shape, &mut all); + } + all.into_iter().filter(|s| r.intersects(s.visual_bounding_rect())).collect() + } + + fn assert_paint(h: &Harness, id: &str, selected: bool) { + assert_presentation(h, id, selected, true); + } + + fn assert_presentation(h: &Harness, id: &str, selected: bool, check_fill: bool) { + let r = scenario::rect_of(&format!("button:{id}")).unwrap(); + let t = Tokens::get(); + let shapes = button_shapes(h, id); + if check_fill { + assert_eq!( + shapes.iter().any(|s| matches!(s, egui::Shape::Rect(s) if s.rect == r && s.fill == t.selected_bg)), + selected, + "{id} selected fill" + ); + } + assert_eq!(shapes.iter().any(|s| matches!(s, egui::Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == t.accent && points[0].y == r.bottom() - t.space_4)), selected, "{id} underline"); + if h.app.preferences.ribbon_labels { + let text = shapes + .iter() + .find_map(|s| match s { + egui::Shape::Text(s) if r.contains(s.pos) => Some(s), + _ => None, + }) + .unwrap_or_else(|| panic!("missing {id} label")); + let font = &text.galley.job.sections[0].format.font_id; + assert_eq!(*font, if selected { crate::fonts::semibold(t.font_body) } else { FontId::proportional(t.font_body) }); + assert_eq!(text.galley.job.sections[0].format.color, if selected { t.selected_ink } else { t.text }); + } + } + + fn tap(h: &mut Harness, id: &str) { + let at = scenario::handle_at(&format!("button:{id}")).unwrap(); + let before = h.app.session.status_items(); + h.frame_with(crate::test_support::pointer(at, true)); + assert_eq!(h.app.session.status_items(), before, "pointer-down is not a persistent activation"); + for command in PERSISTENT_COMMANDS { + // Existing pointer-down paint is transient and may use the same palette fill. + // The underline, actual weight and published identities distinguish persistence. + assert_presentation(h, command, before.iter().any(|item| item.command == *command), *command != id); + assert_eq!(scenario::handle_at(&format!("ribbon:pressed:{command}")).is_some(), before.iter().any(|item| item.command == *command)); + } + h.frame_with(crate::test_support::pointer(at, false)); + } + + #[test] + fn persistent_pressed_and_search_pulse_keep_independent_effect_and_paint_authority() { + let mut h = harness(); + for enabled in [true, false] { + h.call("ui.prefs", json!({"persistent_ribbon_pressed":enabled,"ribbon_reveal":true})); + h.app.sbox.reveal = Some(crate::shortcut_box::Reveal { + id: "inspect.zebra", + since: h.ctx.input(|input| input.time) - crate::shortcut_box::REVEAL_PULSE / 4.0, + preview: false, + back: "INSPECT".into(), + }); + h.frame(); + let zebra = scenario::rect_of("button:inspect.zebra").unwrap(); + assert_eq!(scenario::rect_of("ribbon:reveal"), Some(zebra)); + assert!(h.app.session.status_items().is_empty(), "a search pulse does not activate an effect"); + assert_presentation(&h, "inspect.zebra", false, false); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + h.frame(); + assert_presentation(&h, "inspect.zebra", enabled, enabled); + let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), "INSPECT"); + assert!(button_shapes(&h, "inspect.zebra").iter().any(|shape| matches!(shape, egui::Shape::Rect(rect) if rect.rect == zebra && rect.stroke.width == 2.0 && rect.stroke.color == accent.color.gamma_multiply(h.app.sbox.reveal.as_ref().unwrap().strength(h.ctx.input(|input| input.time), h.ctx.global_style().animation_time > 0.0)))), "the upstream pulse remains painted above the selected background"); + for (id, active) in [("inspect.draft", true), ("inspect.draft", false)] { + let at = scenario::handle_at(&format!("button:{id}")).unwrap(); + h.frame_with(crate::test_support::pointer(at, true)); + h.frame_with(crate::test_support::pointer(at, false)); + assert_presentation(&h, "inspect.zebra", false, false); + assert_eq!(h.app.session.status_items().iter().any(|item| item.command == id), active); + assert_paint(&h, id, active && enabled); + assert_eq!(scenario::rect_of("ribbon:reveal"), Some(zebra)); + } + assert!(h.app.session.status_items().is_empty()); + h.app.sbox.reveal = None; + h.frame(); + assert!(scenario::rect_of("ribbon:reveal").is_none()); + } + } + + fn open_status_grid(h: &mut Harness, panel: &str) { + h.app.preferences.ribbon_icon_grid = true; + h.app.preferences.ribbon_menus_skip_pinned = false; + h.frames(3); + h.step(&json!({"click":{"handle":format!("panel:INSPECT:{panel}")}})).unwrap(); + // Exact token colours are checked after the native Area fade, not during its opacity ramp. + let settle_frames = (h.ctx.global_style().animation_time * 60.0).ceil() as usize + 2; + h.frames(settle_frames.max(3)); + assert!(scenario::rect_of("toolbar_dropdown").is_some()); + } + + fn close_status_grid(h: &mut Harness, panel: &str) { + // Use the real header toggle so closing a menu never supplies an idle effect Escape. + h.step(&json!({"click":{"handle":format!("panel:INSPECT:{panel}")}})).unwrap(); + h.frames(3); + assert!(scenario::rect_of("toolbar_dropdown").is_none()); + } + + fn status_grid_rect(_h: &Harness, id: &str) -> Rect { + let at = scenario::handle_at(&format!("dropdown:{id}")).unwrap(); + Rect::from_center_size(at, vec2(GRID_CELL, GRID_CELL)).shrink(2.0) + } + + fn assert_status_grid(h: &Harness, id: &str, selected: bool, toggled: Option) { + let rect = status_grid_rect(h, id); + let t = Tokens::get(); + let mut drawn = Vec::new(); + for shape in &h.shapes { + shapes(&shape.shape, &mut drawn); + } + assert_eq!( + drawn.iter().any(|shape| matches!(shape, egui::Shape::Rect(s) if s.rect == rect && s.fill == t.selected_bg)), + selected, + "{id} grid fill" + ); + assert_eq!(drawn.iter().any(|shape| matches!(shape, egui::Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == t.accent && points[0].y == rect.bottom() - t.space_4 && rect.contains(points[0]))), selected, "{id} grid underline"); + let command = solidcraft_engine::find_command(id).unwrap(); + let label = if toggled.is_some() { persistent_button_name(&h.app, command) } else { h.app.tr_label(command.label).to_string() }; + let node = h + .output + .accesskit_update + .as_ref() + .and_then(|tree| { + tree.nodes.iter().find(|(_, node)| { + node.role() == egui::accesskit::Role::Button + && node.label() == Some(label.as_str()) + && node.bounds().is_some_and(|b| rect.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32))) + }) + }) + .map(|(_, node)| node) + .expect("the actual grid button, distinguished from a same-named pinned ribbon button by bounds"); + assert_eq!(node.toggled(), toggled, "{id} grid accessibility"); + } + + #[test] + fn persistent_grid_deferred_paint_retains_partial_and_offscreen_scroll_clips() { + let mut saw_partial = false; + let mut saw_offscreen = false; + for offset in [0.0, 24.0, 96.0] { + let mut h = harness(); + h.app.session.execute("inspect.section", &json!({"plane":"XY","at":4})).unwrap(); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + let effects = h.app.session.status_items(); + let ctx = h.ctx.clone(); + let mut expected = Vec::new(); + let output = ctx.run_ui( + egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 240.0))), focused: true, ..Default::default() }, + |ui| { + expected.clear(); // Context layout passes never accumulate stale paint expectations. + // Leave visible space above the scroll viewport, where a broadened clip leaks. + ui.add_space(80.0); + let mut paints = Vec::new(); + egui::ScrollArea::vertical() + .id_salt("persistent_grid_clip_regression") + .auto_shrink([false, false]) + .max_height(64.0) + .min_scrolled_height(64.0) + .vertical_scroll_offset(offset) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for id in ["inspect.section", "inspect.zebra", "inspect.draft"] { + let command = solidcraft_engine::find_command(id).unwrap(); + let (rect, _) = ui.allocate_exact_size(vec2(GRID_CELL, GRID_CELL), Sense::hover()); + let clip = ui.clip_rect().intersect(rect); + let _ = dropdown_cell(&h.app, ui, command, rect, false, &mut paints); + if id != "inspect.draft" { + expected.push((id, rect.shrink(2.0), clip)); + } + } + }); + // This is deliberately the larger parent Ui, matching the real post-dispatch call. + paint_persistent_ribbon(&h.app, ui, paints, "INSPECT", "ribbon_grid"); + }, + ); + assert_eq!(h.app.session.status_items(), effects, "rendering real active cells never changes their effects"); + for (id, cell, original_clip) in expected { + let mut backgrounds = Vec::new(); + for clipped in &output.shapes { + let mut drawn = Vec::new(); + shapes(&clipped.shape, &mut drawn); + if drawn + .iter() + .any(|shape| matches!(shape, egui::Shape::Rect(rect) if rect.rect == cell && rect.fill == Tokens::get().selected_bg)) + { + backgrounds.push(clipped.clip_rect); + } + } + if original_clip.intersect(cell).is_positive() { + assert!(!backgrounds.is_empty(), "{id} actually paints its visible selected fragment at offset {offset}"); + assert!( + backgrounds.iter().all(|clip| *clip == original_clip), + "{id} retains its original cell/scroll clip, not the parent clip, at offset {offset}" + ); + saw_partial |= !original_clip.contains_rect(cell); + } else { + assert!( + backgrounds.iter().all(|clip| !clip.intersect(cell).is_positive()), + "offscreen {id} must not paint into the space above its scroll viewport" + ); + saw_offscreen = true; + } + } + } + assert!(saw_partial, "the native ScrollArea must actually partly clip an active cell"); + assert!(saw_offscreen, "the native ScrollArea must actually scroll an active cell out of view"); + } + + #[test] + fn persistent_grid_uses_actual_section_and_analysis_with_independent_compatibility_preferences() { + for section in [false, true] { + for ribbon in [false, true] { + let mut h = harness(); + h.app.preferences.viewport_analysis_status = false; + h.app.preferences.viewport_section_status = section; + h.app.preferences.persistent_ribbon_pressed = ribbon; + h.app.session.execute("inspect.zebra", &json!({"stripes":14})).unwrap(); + h.app.session.execute("inspect.section", &json!({"plane":"XY","at":4})).unwrap(); + let effects = h.app.session.status_items(); + open_status_grid(&mut h, "SECTION"); + assert_status_grid(&h, "inspect.section", section && ribbon, (section && ribbon).then_some(egui::accesskit::Toggled::True)); + assert_eq!(h.app.session.status_items(), effects); + close_status_grid(&mut h, "SECTION"); + open_status_grid(&mut h, "SURFACE QUALITY"); + assert_status_grid(&h, "inspect.zebra", ribbon, ribbon.then_some(egui::accesskit::Toggled::True)); + assert_eq!(h.app.session.status_items(), effects, "presentation preferences and menu navigation never mutate effects"); + close_status_grid(&mut h, "SURFACE QUALITY"); + } + } + } + + #[test] + fn persistent_grid_tracks_all_six_analysis_replacements_queries_and_failed_activation() { + let mut h = harness(); + h.app.session.execute("inspect.section", &json!({"plane":"YZ","at":3})).unwrap(); + let cut = h.app.session.section; + for id in PERSISTENT_COMMANDS.iter().copied().filter(|id| *id != "inspect.section") { + h.app.session.execute(id, &json!({})).unwrap(); + let before = h.app.session.status_items(); + h.app.session.execute("view.status_items", &json!({})).unwrap(); + assert!(h.app.session.execute("inspect.draft", &json!({"pull":[0,0,0]})).is_err()); + assert_eq!(h.app.session.status_items(), before); + let panel = if matches!(id, "inspect.draft" | "inspect.accessibility") { "CHECK" } else { "SURFACE QUALITY" }; + open_status_grid(&mut h, panel); + assert_status_grid(&h, id, true, Some(egui::accesskit::Toggled::True)); + assert_eq!(h.app.session.section, cut, "analysis replacement leaves Section independent"); + close_status_grid(&mut h, panel); + } + } + + #[test] + fn persistent_grid_keeps_native_keyboard_focus_and_active_tooltip() { + let mut h = harness(); + h.app.session.execute("inspect.section", &json!({"plane":"XY","at":4})).unwrap(); + open_status_grid(&mut h, "SECTION"); + h.step(&json!({"key":"ArrowRight"})).unwrap(); + h.frames(3); + assert!(scenario::rect_of("ribbon_grid:focus:inspect.section").is_some()); + assert_status_grid(&h, "inspect.section", true, Some(egui::accesskit::Toggled::True)); + h.step(&json!({"move":{"handle":"dropdown:inspect.section"}})).unwrap(); + h.frames(100); + let command = solidcraft_engine::find_command("inspect.section").unwrap(); + let hint = persistent_hint(&h.app, command, true).unwrap(); + let mut drawn = Vec::new(); + for shape in &h.shapes { + shapes(&shape.shape, &mut drawn); + } + assert!( + drawn.iter().any(|shape| matches!(shape, egui::Shape::Text(text) if text.galley.text() == hint)), + "the actual grid uses the same native turn-off tooltip" + ); + assert!(h.app.session.section.is_some()); + } + + #[test] + fn persistent_grid_clear_uses_post_dispatch_session_without_stale_paint_or_cross_effect_clear() { + for (id, panel) in [("inspect.section", "SECTION"), ("inspect.zebra", "SURFACE QUALITY")] { + let mut h = harness(); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + h.app.session.execute("inspect.section", &json!({"plane":"XY","at":4})).unwrap(); + open_status_grid(&mut h, panel); + assert_status_grid(&h, id, true, Some(egui::accesskit::Toggled::True)); + let rect = status_grid_rect(&h, id); + let at = scenario::handle_at(&format!("dropdown:{id}")).unwrap(); + let before = h.app.session.status_items(); + h.frame_with(crate::test_support::pointer(at, true)); + assert_eq!(h.app.session.status_items(), before, "pointer down is not a command activation"); + h.frame_with(crate::test_support::pointer(at, false)); + // Check the release frame, with no settling frame: both paint paths resolve after dispatch. + assert_paint(&h, id, false); + let mut drawn = Vec::new(); + for shape in &h.shapes { + shapes(&shape.shape, &mut drawn); + } + let t = Tokens::get(); + // The pointer still hovers this cell: native hover may share the selected fill. + // Persistence is distinguished by the underline, identity and native toggled state. + assert!( + drawn.iter().any(|shape| matches!(shape, egui::Shape::Rect(s) if s.rect == rect && s.fill == t.accent_soft)), + "{id} retains its normal hovered fill on release" + ); + assert!(!drawn.iter().any(|shape| matches!(shape, egui::Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == t.accent && points[0].y == rect.bottom() - t.space_4 && rect.contains(points[0]))), "{id} has no persistent underline on release"); + assert!(scenario::rect_of(&format!("ribbon_grid:pressed:{id}")).is_none(), "{id} has no published pressed identity on release"); + let command = solidcraft_engine::find_command(id).unwrap(); + let label = persistent_button_name(&h.app, command); + let node = h + .output + .accesskit_update + .as_ref() + .and_then(|tree| { + tree.nodes.iter().find(|(_, node)| { + node.role() == egui::accesskit::Role::Button + && node.label() == Some(label.as_str()) + && node.bounds().is_some_and(|b| rect.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32))) + }) + }) + .map(|(_, node)| node) + .expect("the actual cleared grid button, distinguished from its pinned ribbon button by bounds"); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::False), "{id} grid accessibility is off on the release frame"); + assert_eq!(h.app.session.section.is_some(), id != "inspect.section"); + assert_eq!(h.app.session.analysis.is_some(), id != "inspect.zebra"); + } + } + + #[test] + fn section_persistent_ribbon_uses_independent_status_preference_and_active_toggle() { + let mut h = harness(); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + h.app.session.execute("inspect.section", &json!({"plane":"XY","at":4})).unwrap(); + h.frame(); + assert_paint(&h, "inspect.section", true); + assert_paint(&h, "inspect.zebra", true); + h.app.preferences.viewport_analysis_status = false; + h.frame(); + assert_paint(&h, "inspect.section", true); + h.app.preferences.viewport_section_status = false; + h.frame(); + assert_paint(&h, "inspect.section", false); + assert!(persistent_hint(&h.app, solidcraft_engine::find_command("inspect.section").unwrap(), true).is_none()); + assert!(h.app.session.section.is_some()); + h.app.preferences.viewport_section_status = true; + h.app.preferences.persistent_ribbon_pressed = false; + h.frame(); + assert_paint(&h, "inspect.section", false); + h.app.preferences.persistent_ribbon_pressed = true; + h.frames(2); + tap(&mut h, "inspect.section"); + assert!(h.app.session.section.is_none() && h.app.session.analysis.is_some()); + assert_paint(&h, "inspect.section", false); + assert_paint(&h, "inspect.zebra", true); + } + + #[test] + fn persistent_commands_derive_all_seven_programmatic_effects_and_clear_without_residue() { + let mut h = harness(); + for id in PERSISTENT_COMMANDS { + assert!(!persistent_pressed(&h.app, id)); + h.app.session.execute(id, &if *id == "inspect.section" { json!({"plane":"XY"}) } else { json!({}) }).unwrap(); + h.frame(); // Exactly the first output following the engine mutation. + assert_eq!(h.app.session.status_items().iter().map(|s| s.command.as_str()).collect::>(), [*id]); + for c in PERSISTENT_COMMANDS { + assert_paint(&h, c, c == id); + } + let before = h.app.session.status_items(); + let revision = h.app.session.revision; + h.frame(); + assert_eq!(h.app.session.status_items(), before); + assert_eq!(h.app.session.revision, revision); + h.app.session.execute(id, &json!({"clear":true})).unwrap(); + h.frame(); + assert_paint(&h, id, false); + } + for id in ["inspect.minimum_radius", "solid.extrude"] { + assert!(!persistent_command(id)); + } + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + let before = h.app.session.status_items(); + assert!(h.app.session.execute("inspect.draft", &json!({"pull":[0,0,0]})).is_err()); + h.frame(); + assert_eq!(h.app.session.status_items(), before); + assert_paint(&h, "inspect.zebra", true); + } + + #[test] + fn persistent_replacement_and_toggle_are_correct_in_the_click_release_frame_both_directions() { + for labels in [true, false] { + let mut h = harness(); + h.app.preferences.ribbon_labels = labels; + crate::toolbar_custom::reorder(&mut h.app, "INSPECT", "inspect.draft", "SURFACE QUALITY", Some("inspect.zebra")); + h.frames(2); + for id in ["inspect.zebra", "inspect.draft", "inspect.zebra", "inspect.continuity", "inspect.environment_map"] { + tap(&mut h, id); // No settling frame after release. + assert_eq!(h.app.session.status_items().iter().map(|s| s.command.as_str()).collect::>(), [id]); + for c in PERSISTENT_COMMANDS { + assert_paint(&h, c, *c == id); + } + let c = solidcraft_engine::find_command(id).unwrap(); + assert_eq!( + persistent_hint(&h.app, c, true), + Some(format!("Turn off {} · Esc", h.app.tr_label(crate::workspace::ribbon_button_label(c)))) + ); + } + tap(&mut h, "inspect.environment_map"); + assert!(h.app.session.status_items().is_empty()); + assert_paint(&h, "inspect.environment_map", false); + tap(&mut h, "inspect.continuity"); + tap(&mut h, "inspect.continuity"); + assert!(h.app.session.status_items().is_empty()); + assert_paint(&h, "inspect.continuity", false); + } + } + + #[test] + fn persistent_presentation_preference_preserves_old_help_and_legacy_two_effects() { + let mut h = harness(); + h.call("ui.prefs", json!({"single_surface_analysis":false})); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + h.app.session.execute("inspect.continuity", &json!({})).unwrap(); + h.frame(); + assert_eq!(h.app.session.status_items().len(), 2); + assert_paint(&h, "inspect.zebra", true); + assert_paint(&h, "inspect.continuity", true); + let before = h.app.session.status_items(); + h.app.preferences.persistent_ribbon_pressed = false; + h.frame(); + for id in PERSISTENT_COMMANDS { + assert_paint(&h, id, false); + } + assert_eq!(h.app.session.status_items(), before); + let label = h.app.tr_label(solidcraft_engine::find_command("inspect.zebra").unwrap().info(&h.app.session).label); + let old_node = h + .output + .accesskit_update + .as_ref() + .and_then(|tree| tree.nodes.iter().find(|(_, n)| n.label() == Some(label))) + .map(|(_, n)| n) + .expect("old neutral accessible button label"); + assert_eq!(old_node.toggled(), None, "the old presentation makes no false inactive-effect announcement"); + assert!(persistent_hint(&h.app, solidcraft_engine::find_command("inspect.zebra").unwrap(), true).is_none()); + h.app.preferences.persistent_ribbon_pressed = true; + h.frame(); + assert_paint(&h, "inspect.zebra", true); + assert_paint(&h, "inspect.continuity", true); + h.app.last_command = Some(("inspect.draft".into(), "Draft".into())); + h.frame(); + assert_paint(&h, "inspect.draft", false); + assert!(persistent_hint(&h.app, solidcraft_engine::find_command("inspect.zebra").unwrap(), false).is_none()); + } + + #[test] + fn persistent_selected_shapes_and_real_semibold_survive_every_colour_vision_mode() { + let mut h = harness(); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + for dark in [false, true] { + for vision in ["off", "protan", "deutan", "tritan"] { + h.call("ui.prefs", json!({"theme":if dark { "dark" } else { "light" },"color_vision":vision})); + h.frame(); + assert_paint(&h, "inspect.zebra", true); + assert_paint(&h, "inspect.curvature_map", false); + } + } + let painter = h.ctx.layer_painter(egui::LayerId::background()); + let plain = ribbon_button_galley_weight(&painter, "Zebra", 150.0, Tokens::get().text, false); + let heavy = ribbon_button_galley_weight(&painter, "Zebra", 150.0, Tokens::get().text, true); + assert!((plain.size().x - heavy.size().x).abs() > 0.1, "actual 600-weight face has different glyph metrics"); + } + + #[test] + fn persistent_keyboard_focus_uses_native_response_and_clears_with_space() { + let mut h = harness(); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + h.frame(); + let at = scenario::handle_at("button:inspect.zebra").unwrap(); + // A pointer press establishes the real existing response's keyboard focus. + h.frame_with(crate::test_support::pointer(at, true)); + h.frame_with(crate::test_support::pointer(at, false)); + let id = h.ctx.memory(|m| m.focused()).expect("native ribbon response is focusable"); + assert!(h.app.session.status_items().is_empty()); + h.ctx.memory_mut(|m| m.request_focus(id)); + h.frame_with(crate::test_support::key(egui::Key::Space)); + assert_eq!(h.app.session.status_items().len(), 1); + assert_paint(&h, "inspect.zebra", true); + assert!(scenario::rect_of("ribbon:focus:inspect.zebra").is_some()); + let node = h + .output + .accesskit_update + .as_ref() + .and_then(|tree| tree.nodes.iter().find(|(_, n)| n.label() == Some("Zebra"))) + .map(|(_, n)| n) + .expect("accessible native Zebra button"); + assert_eq!(node.role(), egui::accesskit::Role::Button); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True)); + } + + #[test] + fn persistent_tooltip_is_the_exact_translated_visible_label_and_old_help_is_reachable() { + let mut h = harness(); + h.app.session.execute("inspect.zebra", &json!({})).unwrap(); + for language in ["en", "de", "ja"] { + h.call("ui.prefs", json!({"language":language})); + let command = solidcraft_engine::find_command("inspect.zebra").unwrap(); + let name = h.app.tr_label(crate::workspace::ribbon_button_label(command)); + let expected = crate::i18n::Locale::for_language(language).format("ui.viewport_status_clear_hint", &[("name", name)]); + assert_eq!(persistent_hint(&h.app, command, true), Some(expected.clone())); + let at = scenario::handle_at("button:inspect.zebra").unwrap(); + h.frame_with(vec![egui::Event::PointerMoved(at)]); + h.frames(100); + let mut all = Vec::new(); + for shape in &h.shapes { + shapes(&shape.shape, &mut all); + } + assert!(all.iter().any(|s| matches!(s, egui::Shape::Text(t) if t.galley.text() == expected)), "actual tooltip {expected}"); + } + h.app.preferences.persistent_ribbon_pressed = false; + h.frame_with(vec![egui::Event::PointerMoved(egui::Pos2::ZERO)]); + h.frame_with(vec![egui::Event::PointerMoved(scenario::handle_at("button:inspect.zebra").unwrap())]); + h.frames(100); + assert!(persistent_hint(&h.app, solidcraft_engine::find_command("inspect.zebra").unwrap(), true).is_none()); + assert_paint(&h, "inspect.zebra", false); + } + + #[test] + fn persistent_dropdown_clear_keeps_native_pin_and_command_routes() { + let mut h = harness(); + h.app.session.execute("inspect.environment_map", &json!({})).unwrap(); + h.frame(); + h.step(&json!({"click":{"handle":"panel:INSPECT:SURFACE QUALITY"}})).unwrap(); + assert!(scenario::handle_at("dropdown:inspect.environment_map").is_some()); + h.step(&json!({"click":{"handle":"dropdown:inspect.environment_map"}})).unwrap(); + assert!(h.app.session.status_items().is_empty()); + assert_paint(&h, "inspect.environment_map", false); + } + + #[test] + fn persistent_registration_preserves_live_continuity_tool_without_a_status_item() { + let mut h = harness(); + h.app.start("inspect.continuity"); + assert_eq!(h.app.tool.as_ref().map(|tool| tool.cmd), Some("inspect.continuity")); + h.app.session.execute("inspect.continuity", &json!({"clear":true})).unwrap(); + h.frame(); + assert!(h.app.session.status_items().is_empty()); + assert_eq!(scenario::count_of("ribbon:pressed"), Some(0.0)); + assert_presentation(&h, "inspect.continuity", false, false); + let r = scenario::rect_of("button:inspect.continuity").unwrap(); + let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), "INSPECT"); + assert!( + button_shapes(&h, "inspect.continuity") + .iter() + .any(|s| matches!(s, egui::Shape::Rect(s) if s.rect == r && s.fill == accent.soft && s.stroke.color == accent.color)), + "the real live tool retains its transient indication" + ); + h.app.session.execute("inspect.continuity", &json!({})).unwrap(); + h.frame(); + assert_paint(&h, "inspect.continuity", true); + // A dialog's last-command fallback must not invent an analysis tool. + h.app.tool = None; + h.app.start("solid.box"); + assert!(h.app.dialog.is_some()); + h.app.ui.tab = "INSPECT".into(); + h.app.last_command = Some(("inspect.zebra".into(), "Zebra".into())); + h.frame(); + assert_paint(&h, "inspect.zebra", false); + let r = scenario::rect_of("button:inspect.zebra").unwrap(); + assert!( + !button_shapes(&h, "inspect.zebra") + .iter() + .any(|s| matches!(s, egui::Shape::Rect(s) if s.rect == r && s.fill == accent.soft && s.stroke.color == accent.color)), + "last_command is not a live analysis tool" + ); + } + + #[test] + fn persistent_click_clear_and_replacement_remove_old_tool_paint_in_the_release_frame() { + fn no_tool_paint(h: &Harness) { + let r = scenario::rect_of("button:inspect.continuity").unwrap(); + let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), "INSPECT"); + assert!( + !button_shapes(h, "inspect.continuity") + .iter() + .any(|s| matches!(s, egui::Shape::Rect(s) if s.rect == r && s.fill == accent.soft && s.stroke.color == accent.color)), + "no captured old live-tool fill/border after dispatch" + ); + assert!(scenario::handle_at("ribbon:pressed:inspect.continuity").is_none()); + assert_paint(h, "inspect.continuity", false); + } + for labels in [true, false] { + let mut h = harness(); + h.app.preferences.ribbon_labels = labels; + crate::toolbar_custom::reorder(&mut h.app, "INSPECT", "inspect.continuity", "SURFACE QUALITY", Some("inspect.environment_map")); + h.frames(2); + tap(&mut h, "inspect.continuity"); + assert!(h.app.tool.is_some()); + tap(&mut h, "inspect.continuity"); + assert!(h.app.tool.is_none() && h.app.session.status_items().is_empty()); + no_tool_paint(&h); + tap(&mut h, "inspect.continuity"); + tap(&mut h, "inspect.environment_map"); + assert!(h.app.tool.is_none()); + assert_eq!(h.app.session.status_items()[0].command, "inspect.environment_map"); + no_tool_paint(&h); + assert_paint(&h, "inspect.environment_map", true); + } + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 24cd85a7e9..26c698eae9 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -1155,13 +1155,14 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { if snap.as_deref() == Some("Midpoint") && t.kind == Kind::HorizontalVertical { painter.circle_stroke(sp, 8.0, Stroke::new(2.0, tk.accent)); } - painter.text( - sp + egui::vec2(10.0, 10.0), + let readout = painter.text( + crate::tool_cursor::readout_at(painter, proj.rect, sp), egui::Align2::LEFT_TOP, format!("{}, {}", crate::prefs::show_mm(app, hp.x, 1), crate::prefs::show_mm(app, hp.y, 1)), egui::FontId::proportional(crate::theme::Tokens::get().font_small), tk.text_dim, ); + crate::scenario::publish_rect("viewport:readout", readout); } if matches!(t.kind, Kind::HorizontalVertical | Kind::Dimension) { for target in &t.point_picks { diff --git a/crates/ui-egui/src/ui_kit/actions.rs b/crates/ui-egui/src/ui_kit/actions.rs index df3a4bf4fb..898d94c1f2 100644 --- a/crates/ui-egui/src/ui_kit/actions.rs +++ b/crates/ui-egui/src/ui_kit/actions.rs @@ -443,6 +443,37 @@ pub enum Button { SmallSelected, } +/// Selected palette shared by SmallSelected and opt-in persistent native buttons. +fn selected_button_palette(t: &Tokens) -> (egui::Color32, egui::Color32) { + (t.selected_bg, t.selected_ink) +} + +/// Persistent counterpart of SmallSelected at a caller's native rectangle. This opt-in recipe +/// leaves existing SmallSelected controls unchanged. Tokens: selected_bg/ink, form_primary, +/// accent, radius_sm, space_8. The underline is two logical pixels; focus uses native visuals. +pub(crate) fn persistent_button_shape(ui: &Ui, response: &Response, rect: egui::Rect) -> egui::Shape { + let t = Tokens::get(); + let (fill, _) = selected_button_palette(&t); + let mut shapes = vec![ + egui::Shape::rect_filled(rect, t.radius_sm, fill), + egui::Shape::rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.form_primary), egui::StrokeKind::Inside), + ]; + let inset = t.space_8.min(rect.width() / 4.0); + let y = rect.bottom() - t.space_4; + shapes + .push(egui::Shape::line_segment([egui::pos2(rect.left() + inset, y), egui::pos2(rect.right() - inset, y)], egui::Stroke::new(2.0, t.accent))); + if response.has_focus() { + shapes.push(native_button_focus(ui, response, rect)); + } + egui::Shape::Vec(shapes) +} + +/// The same standard focused-button outline used by native egui buttons, without resizing. +pub(crate) fn native_button_focus(ui: &Ui, response: &Response, rect: egui::Rect) -> egui::Shape { + let visuals = ui.style().interact(response); + 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) { @@ -457,7 +488,8 @@ fn button_widget(label: egui::WidgetText, options: Button) -> egui::Button<'stat } Button::Ghost => egui::Button::new(label).frame(false), Button::SmallSelected => { - egui::Button::new(label.color(t.selected_ink)).small().fill(t.selected_bg).stroke(egui::Stroke::new(1.0, t.form_primary)) + 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), } diff --git a/crates/ui-egui/src/ui_kit/actions_tests.rs b/crates/ui-egui/src/ui_kit/actions_tests.rs index 9fb9f2bb19..e3c60a8a7d 100644 --- a/crates/ui-egui/src/ui_kit/actions_tests.rs +++ b/crates/ui-egui/src/ui_kit/actions_tests.rs @@ -263,3 +263,57 @@ fn one_bit_primary_actions_keep_inverted_labels_and_legacy_fills() { } } } + +#[test] +fn persistent_native_selected_recipe_is_opt_in_and_preserves_small_selected_controls() { + fn flatten(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => { + for s in shapes { + flatten(s, out); + } + } + s => out.push(s.clone()), + } + } + let mut themes: Vec<_> = [false, true] + .into_iter() + .flat_map(|dark| crate::theme::ColorVision::ALL.into_iter().map(move |vision| crate::theme::Tokens::themed(dark, vision))) + .collect(); + themes.extend(crate::theme_files::builtin().iter().map(crate::theme_files::resolve).filter(|t| t.borders.bevel.is_some())); + assert!(themes.iter().any(|t| t.borders.bevel.is_some()), "include a real bevel theme"); + for t in themes { + let _theme = crate::theme::scoped_tokens(t); + let ctx = crate::fonts::test_context(); + let mut selected = egui::Shape::Noop; + let mut native = egui::Rect::NOTHING; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let response = button(ui, "Existing selected", Button::SmallSelected); + native = response.rect; + selected = persistent_button_shape(ui, &response, response.rect); + }); + output.textures_delta.clear(); + let mut shapes = Vec::new(); + flatten(&selected, &mut shapes); + assert!(shapes.iter().any(|s| matches!(s, egui::Shape::Rect(r) if r.rect == native && r.fill == t.selected_bg))); + assert!(shapes.iter().any(|s| matches!(s, egui::Shape::LineSegment { stroke, .. } if stroke.width == 2.0 && stroke.color == t.accent))); + // Existing SmallSelected keeps its old fill (including the bevel policy), regular + // label family and no persistent underline. Merely asking for the opt-in shape + // cannot alter that widget or its native geometry. + let mut old = Vec::new(); + for shape in output.shapes { + flatten(&shape.shape, &mut old); + } + let fill = if t.borders.bevel.is_some() { t.control_fill } else { t.selected_bg }; + assert!(old.iter().any(|s| matches!(s, egui::Shape::Rect(r) if r.rect == native && r.fill == fill))); + assert!(!old.iter().any(|s| matches!(s, egui::Shape::LineSegment { stroke, .. } if stroke.width == 2.0 && stroke.color == t.accent))); + let text = old + .iter() + .find_map(|s| match s { + egui::Shape::Text(s) if s.galley.text() == "Existing selected" => Some(s), + _ => None, + }) + .unwrap(); + assert_eq!(text.galley.job.sections[0].format.font_id.family, egui::FontFamily::Proportional); + } +} diff --git a/crates/ui-egui/src/ui_kit/assistant.rs b/crates/ui-egui/src/ui_kit/assistant.rs index e20a333c01..8a9f28d89d 100644 --- a/crates/ui-egui/src/ui_kit/assistant.rs +++ b/crates/ui-egui/src/ui_kit/assistant.rs @@ -232,21 +232,37 @@ pub(crate) fn assistant_approval(ui: &mut Ui, title: &str, body: impl FnOnce( #[cfg(any(not(target_arch = "wasm32"), test))] pub(crate) fn assistant_input(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { let t = Tokens::get(); + // The hint is painted here, not by the TextEdit: a multiline edit wraps its hint onto + // several rows, and the hint must stay on one row, ending in an ellipsis when it does not fit. let mut response = ui.add( super::fields::text_input(egui::TextEdit::multiline(value)) .id(id) .frame(egui::Frame::NONE) .background_color(egui::Color32::TRANSPARENT) - .hint_text(hint) .desired_rows(2) .desired_width(f32::INFINITY) .min_size(vec2(0.0, t.control_height + t.space_8)) .font(egui::FontId::proportional(t.font_label)), ); super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINES); + if value.is_empty() { + let width = (response.rect.width() - 2.0 * t.form_inset).max(0.0); + let galley = assistant_hint_galley(ui, hint, width); + let pos = egui::pos2(response.rect.left() + t.form_inset, response.rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley, ui.visuals().weak_text_color()); + } response } +/// The composer hint laid out on one row of at most `width`, cut with an ellipsis. +#[cfg(any(not(target_arch = "wasm32"), test))] +pub(crate) fn assistant_hint_galley(ui: &Ui, hint: &str, width: f32) -> std::sync::Arc { + let t = Tokens::get(); + let mut job = egui::text::LayoutJob::simple_singleline(hint.to_owned(), egui::FontId::proportional(t.font_label), ui.visuals().weak_text_color()); + job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}') }; + ui.fonts_mut(|f| f.layout_job(job)) +} + /// Compact right-aligned approval actions; wrapping grows by control rows, not the remaining card height. #[cfg(any(not(target_arch = "wasm32"), test))] pub(crate) fn assistant_actions(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { @@ -258,6 +274,13 @@ pub(crate) fn assistant_actions(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) ) } +/// Bottom corners follow the window; the top meets the footer rule square. +#[cfg(any(not(target_arch = "wasm32"), test))] +fn composer_corners(t: &Tokens) -> egui::CornerRadius { + let r = t.radius_xl.saturating_sub(1); + egui::CornerRadius { nw: 0, ne: 0, sw: r, se: r } +} + /// Composer's rounded field surface with an accent border only while focused. #[cfg(any(not(target_arch = "wasm32"), test))] pub(crate) fn assistant_composer(ui: &mut Ui, focused: bool, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { @@ -265,7 +288,7 @@ pub(crate) fn assistant_composer(ui: &mut Ui, focused: bool, add: impl FnOnce egui::Frame::NONE .fill(t.control_fill) .stroke(if focused { egui::Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }) - .corner_radius(t.radius_xl) + .corner_radius(composer_corners(&t)) .inner_margin(egui::Margin::symmetric(t.space_10 as i8, t.space_8 as i8)) .show(ui, |ui| { ui.set_min_width(ui.available_width().max(0.0)); @@ -303,8 +326,10 @@ pub(crate) fn assistant_composer_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui let t = Tokens::get(); let output = egui::Frame::NONE .fill(t.dialog_footer) - .corner_radius(egui::CornerRadius { nw: 0, ne: 0, sw: t.radius_xl.saturating_sub(1), se: t.radius_xl.saturating_sub(1) }) - .inner_margin(t.space_10) + .corner_radius(composer_corners(&t)) + // No margin: the composer box inside fills this footer edge to edge. The text padding + // lives on the box itself, so there is one inset, not two. + .inner_margin(0) .show(ui, add); t.secondary_hline(ui.painter(), output.response.rect.x_range(), output.response.rect.top(), t.dialog_rule); output diff --git a/crates/ui-egui/src/ui_kit/checks.rs b/crates/ui-egui/src/ui_kit/checks.rs index ae109d5105..7883bcb7a3 100644 --- a/crates/ui-egui/src/ui_kit/checks.rs +++ b/crates/ui-egui/src/ui_kit/checks.rs @@ -58,6 +58,7 @@ pub(crate) fn checkbox(ui: &mut Ui, label: &str, value: &mut bool) -> Response { /// Mixed values become checked when activated; the caller owns whether selection is mixed. /// Tokens: space_14/3/2/6, radius_sm, control_fill/border, form_primary/ink, hover, on_accent. pub(crate) fn checkbox_indeterminate(ui: &mut Ui, label: &str, value: &mut bool, mixed: bool) -> Response { + crate::prefs_search::note_unlabelled(ui, &[label]); if crate::prefs_search::querying(ui) && !crate::prefs_search::visible(ui, &[label]) { return crate::prefs_search::hidden_response(ui); } diff --git a/crates/ui-egui/src/ui_kit/choice.rs b/crates/ui-egui/src/ui_kit/choice.rs index af451912c9..e5325c8368 100644 --- a/crates/ui-egui/src/ui_kit/choice.rs +++ b/crates/ui-egui/src/ui_kit/choice.rs @@ -33,6 +33,7 @@ pub(crate) struct Segments<'a> { } pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Segments<'_>) -> bool { + crate::prefs_search::note_unlabelled(ui, options); if crate::prefs_search::suppress_chrome(ui) && !crate::prefs_search::texts_hit(ui, options) { return false; } @@ -608,6 +609,9 @@ pub(crate) struct PopupOption<'a> { pub color: egui::Color32, } +/// Rows a completion list shows before it scrolls. +const POPUP_LIST_ROWS: usize = 8; + /// Focus-neutral suggestion popup. The owner handles navigation and completion semantics; /// `selected` is the highlighted row, or `None` when no row is picked yet (#3663). Its rows /// take no focus, and where they were drawn is kept under `id.with("rows")`, so an owner can @@ -647,31 +651,56 @@ pub(crate) fn popup_list( .show(ui, |ui| { super::scope(ui, |ui| { ui.set_width(width); - for (index, option) in options.iter().enumerate() { - let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), t.control_height), egui::Sense::CLICK); - rows.push(rect); - let picked = selected == Some(index); - if picked || response.hovered() { - ui.painter().rect_filled(rect, t.control_radius, if picked { t.selected_bg } else { t.hover }); + // A long list (every parameter, #9246) scrolls past its first rows and keeps + // the chosen row in view. + let scrolls = options.len() > POPUP_LIST_ROWS; + let followed = id.with("followed"); + let follow = scrolls && selected.is_some() && ctx.data(|d| d.get_temp::>(followed)) != Some(selected); + ctx.data_mut(|d| d.insert_temp(followed, selected)); + let scroll = egui::ScrollArea::vertical() + .id_salt(id.with("scroll")) + .max_height(t.control_height * POPUP_LIST_ROWS as f32) + .auto_shrink([false, true]) + .vscroll(scrolls); + scroll.show(ui, |ui| { + for (index, option) in options.iter().enumerate() { + let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), t.control_height), egui::Sense::CLICK); + if follow && selected == Some(index) { + ui.scroll_to_rect_animation(rect, None, egui::style::ScrollAnimation::none()); + } + // A row scrolled out of view is not under the pointer. + rows.push(if ui.is_rect_visible(rect) { rect.intersect(ui.clip_rect()) } else { egui::Rect::NOTHING }); + let picked = selected == Some(index); + if picked || response.hovered() { + ui.painter().rect_filled(rect, t.control_radius, if picked { t.selected_bg } else { t.hover }); + } + let inner = rect.shrink2(vec2(t.space_8, 0.0)); + let kind = crate::i18n::tr_label(option.kind); + let kind_width = super::metrics::label_width(ui, kind); + let text_rect = egui::Rect::from_min_max( + inner.min, + egui::pos2((inner.right() - kind_width - t.space_8).max(inner.left()), inner.bottom()), + ); + super::text::paint_text( + ui, + text_rect, + option.text, + mono.clone(), + if picked { t.form_primary } else { option.color }, + false, + ); + ui.painter().text(inner.right_center(), egui::Align2::RIGHT_CENTER, kind, kind_font.clone(), t.form_dim); + response + .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), picked, option.text)); + // Tip the full name first: long identifiers still clip inside a capped popup. + let tip = if option.detail.is_empty() { option.text.to_owned() } else { format!("{}\n{}", option.text, option.detail) }; + let response = response.on_hover_text(tip); + crate::scenario::publish_handle(&format!("dialog:suggestion:{index}"), response.rect.center()); + if response.clicked() { + chosen = Some(index); + } } - let inner = rect.shrink2(vec2(t.space_8, 0.0)); - let kind = crate::i18n::tr_label(option.kind); - let kind_width = super::metrics::label_width(ui, kind); - let text_rect = egui::Rect::from_min_max( - inner.min, - egui::pos2((inner.right() - kind_width - t.space_8).max(inner.left()), inner.bottom()), - ); - super::text::paint_text(ui, text_rect, option.text, mono.clone(), if picked { t.form_primary } else { option.color }, false); - ui.painter().text(inner.right_center(), egui::Align2::RIGHT_CENTER, kind, kind_font.clone(), t.form_dim); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), picked, option.text)); - // Tip the full name first: long identifiers still clip inside a capped popup. - let tip = if option.detail.is_empty() { option.text.to_owned() } else { format!("{}\n{}", option.text, option.detail) }; - let response = response.on_hover_text(tip); - crate::scenario::publish_handle(&format!("dialog:suggestion:{index}"), response.rect.center()); - if response.clicked() { - chosen = Some(index); - } - } + }); super::divider(ui, "completion_detail"); if let Some(option) = selected.and_then(|index| options.get(index)) { super::text(ui, option.detail, super::Text::Note("completion_detail")); diff --git a/crates/ui-egui/src/ui_kit/choice_tests.rs b/crates/ui-egui/src/ui_kit/choice_tests.rs index 23d9fe57d3..8103303f2b 100644 --- a/crates/ui-egui/src/ui_kit/choice_tests.rs +++ b/crates/ui-egui/src/ui_kit/choice_tests.rs @@ -147,7 +147,7 @@ fn optional_field_keeps_empty_default_and_options_use_two_columns() { /// really is too narrow. #[test] fn segments_stay_segments_when_a_scrollbar_appears_and_disappears() { - let options = ["Origin", "View centre", "Point under the cursor"]; + let options = ["Origin", "View center", "Point under the cursor"]; let solid = egui::style::ScrollStyle::solid(); let scrollbar = solid.allocated_width(); assert!(scrollbar > 0.0); diff --git a/crates/ui-egui/src/ui_kit/container.rs b/crates/ui-egui/src/ui_kit/container.rs index c647fa4224..d49407f6ce 100644 --- a/crates/ui-egui/src/ui_kit/container.rs +++ b/crates/ui-egui/src/ui_kit/container.rs @@ -146,6 +146,18 @@ impl Container { }); } + /// [`Self::tooltip`] holding what `add` draws (a picture, say), beside `response` rather than + /// at the pointer, so it does not cover the row's neighbours. + pub(crate) fn tooltip_ui(response: &egui::Response, add: impl FnOnce(&mut egui::Ui)) { + let ctx = &response.ctx; + let mut tip = egui::Tooltip::for_enabled(response); + tip.popup = tip.popup.align(egui::RectAlign::RIGHT_START).frame(Self::new(ctx, Surface::Toast).tooltip_frame(ctx)); + tip.show(|ui| { + add(ui); + crate::scenario::publish_rect("kit:tooltip", ui.min_rect()); + }); + } + /// Declare contacts from shell geometry; styling remains entirely in the container. pub fn joined(mut self, edges: DockEdges) -> Self { self.joined = Some(edges); @@ -298,9 +310,9 @@ impl Container { } pub fn area(self, id: Id) -> egui::Area { - // Persistent workspace cards stay below tool/system windows even when clicked. - // Other areas keep egui's normal window layer; popups choose their own order. - let order = if self.surface == Surface::Panel { egui::Order::Background } else { egui::Order::Middle }; + // 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 }; egui::Area::new(id).enabled(self.input_enabled()).order(order) } diff --git a/crates/ui-egui/src/ui_kit/fields.rs b/crates/ui-egui/src/ui_kit/fields.rs index 0ecf16e93a..7718a7464f 100644 --- a/crates/ui-egui/src/ui_kit/fields.rs +++ b/crates/ui-egui/src/ui_kit/fields.rs @@ -305,6 +305,24 @@ pub(crate) fn dropdown(ui: &mut Ui, id: &str, selected: &mut usize, options: &[& }) } +/// [`dropdown`] whose options each show a picture beside them while hovered, painted by `preview` +/// for that option's index, so a choice can be seen before it is picked. Tokens: dropdown tokens, +/// the tooltip frame. +pub(crate) fn dropdown_previews(ui: &mut Ui, id: &str, selected: &mut usize, options: &[&str], preview: impl Fn(&mut Ui, usize)) -> Response { + let text = options.get(*selected).copied().map(crate::i18n::tr_label).unwrap_or(""); + let row = label(ui).to_string(); + dropdown_menu(ui, id, text, |ui| { + for (index, option) in options.iter().enumerate() { + let response = ui.selectable_value(selected, index, crate::i18n::tr_label(option)); + scenario::publish_target(&format!("dialog:{row}:{option}"), response.rect); + Container::tooltip_ui(&response, |ui| { + preview(ui, index); + scenario::publish_rect(&format!("dialog:preview:{row}:{option}"), ui.min_rect()); + }); + } + }) +} + /// [`dropdown`] that also says whether an option was clicked, the one already shown included (a /// pick of the shown option changes nothing, yet it is a choice). pub(crate) fn dropdown_picked(ui: &mut Ui, id: &str, selected: &mut usize, options: &[&str]) -> bool { @@ -544,6 +562,92 @@ fn cell_editor_frame(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, response } +/// Table cell editor that opens wide while it has the focus and its text no longer fits +/// (#9358): over the cells to its right up to `right`, wrapping onto as many lines as the text +/// needs, with `note` (the live value, or an error when its flag is set) under it. Otherwise, +/// and when there is no room to its right, it is [`cell_editor`]. Enter does not break the +/// line: the caller commits on it as for the cell. Tokens: cell_editor tokens, popup frame. +pub fn wide_cell_editor(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, invalid: bool, right: f32, note: (&str, bool)) -> Response { + let t = Tokens::get(); + let cell = egui::Rect::from_min_size(ui.cursor().min, vec2(dimension(ui.available_width()), row_height())); + let focused = ui.memory(|m| m.has_focus(id) || m.had_focus_last_frame(id)); + let font = egui::FontId::monospace(t.font_label); + let text_width = ui.fonts_mut(|f| f.layout_no_wrap(value.clone(), font.clone(), t.form_ink)).size().x; + let fits = text_width + 2.0 * t.form_inset <= cell.width(); + if !focused || fits || right <= cell.right() || !ui.is_rect_visible(cell) { + return cell_editor(ui, id, value, hint, invalid); + } + // The cell keeps its place in the row; the editor itself floats over it. + let (rect, _) = ui.allocate_exact_size(cell.size(), egui::Sense::hover()); + ui.painter().rect_filled(rect, t.control_radius, if invalid { t.form_error_fill } else { t.input_fill() }); + let width = right - rect.left(); + let (ctx, enabled) = (ui.ctx().clone(), ui.is_enabled()); + // The border sits outside the cell, so the text stays where it was. + let border = t.primary_border(t.dialog_border); + let area = Container::new(&ctx, Surface::Popup) + .area(id.with("wide")) + .fixed_pos(rect.min - vec2(border.width, border.width)) + .constrain(true) + .order(egui::Order::Foreground); + let shown = area.show(&ctx, |ui| { + Container::popup_frame(ui.style()) + .fill(t.control_fill) + .stroke(border) + .corner_radius(t.control_radius) + .inner_margin(0.0) + .show(ui, |ui| { + ui.set_width(width); + let editor = text_input(egui::TextEdit::multiline(value)) + .id(id) + .return_key(None) + .desired_rows(1) + .desired_width(width) + .hint_text(crate::i18n::tr_label(hint)) + .font(font) + .background_color(if invalid { t.form_error_fill } else { t.input_fill() }) + .text_color(if invalid { t.form_error } else { t.form_ink }); + let mut response = ui.add(editor); + // The area's first frame is a sizing pass that disables its widgets, and egui + // takes the focus from a disabled one: hand it straight back. + if ui.is_sizing_pass() { + response.request_focus(); + } + // It wraps but stays one line: Enter ends the edit as in a single-line field. + // Raw events, as the table reads them: an accepted completion has already taken its Enter. + let enter = ui.input(|i| { + i.raw.events.iter().any(|e| { + matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if *modifiers == egui::Modifiers::NONE) + }) + }); + if response.has_focus() && enter { + response.surrender_focus(); + } + super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINE); + publish(ui, &response, egui::WidgetType::TextEdit); + crate::theme::TabAccent::number_focus(ui, &response); + t.input_bevel(ui, &response); + let (text, error) = note; + if !text.is_empty() { + ui.add_space(t.space_2); + ui.horizontal(|ui| { + ui.add_space(t.form_inset); + ui.add(egui::Label::new(RichText::new(text).size(t.font_small).color(if error { t.form_error } else { t.form_dim })).wrap()); + }); + ui.add_space(t.space_2); + } + response + }) + .inner + }); + let response = shown.inner; + response.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, enabled, crate::i18n::tr_label(hint)); + info.current_text_value = Some(value.clone()); + info + }); + response +} + /// Search input with native accessibility and a stable caller ID. Tokens: cell_editor tokens. pub(crate) fn search_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { if !super::layout::in_catalog(ui) { @@ -923,6 +1027,36 @@ pub(crate) fn number_fields( .collect() } +/// A number on a range track, without unit: dragged `speed` a point, shown with `decimals`, typed +/// as a sum of numbers (`relative_number`). +/// Tokens: scope control/font tokens. Values are caller data. +pub(crate) fn track_number(ui: &mut Ui, value: &mut f64, speed: f64, decimals: usize) -> Response { + let now = *value; + let response = ui.add(egui::DragValue::new(value).speed(speed).fixed_decimals(decimals).custom_parser(move |s| relative_number(s, now))); + Tokens::get().input_bevel(ui, &response); + super::layout::publish_geometry(ui, &response); + response +} + +/// A value typed as a sum of numbers (`crate::prefs::parse_number` each), added to `now` when it +/// starts with a sign: "+30" moves the value by 30, "0-5" is −5. +fn relative_number(text: &str, now: f64) -> Option { + let text: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect(); + let mut total = if text.starts_with(['+', '-']) { now } else { 0.0 }; + let mut term = String::new(); + let mut prev = ' '; + for c in text.chars() { + if (c == '+' || c == '-') && !term.is_empty() && !matches!(prev, 'e' | 'E') { + total += crate::prefs::parse_number(&term)?; + term.clear(); + } + term.push(c); + prev = c; + } + total += crate::prefs::parse_number(&term)?; + total.is_finite().then_some(total) +} + /// Growing plain-text message editor; its parent owns scrolling and Enter routing. /// Tokens: control_fill, form_inset, font_label. Use text_area for bounded data input. #[cfg(any(not(target_arch = "wasm32"), test))] diff --git a/crates/ui-egui/src/ui_kit/fields_tests.rs b/crates/ui-egui/src/ui_kit/fields_tests.rs index 4cc2e3814a..c05710673f 100644 --- a/crates/ui-egui/src/ui_kit/fields_tests.rs +++ b/crates/ui-egui/src/ui_kit/fields_tests.rs @@ -665,3 +665,15 @@ fn esc_closes_the_text_menu_alone() { menu_frame(&ctx, key(egui::Key::Escape), &mut draw); assert!(owner_saw_esc.get(), "with the menu closed, Esc reaches the owner again"); } + +#[test] +fn a_track_number_adds_up_and_a_sign_adds_to_the_value_now() { + let typed = super::relative_number; + assert_eq!(typed("1200", 5.0), Some(1200.0)); + assert_eq!(typed("+30", 100.0), Some(130.0)); + assert_eq!(typed(" -30 ", 100.0), Some(70.0)); + assert_eq!(typed("\u{2212}2,5", 10.0), Some(7.5)); + assert_eq!(typed("0-5", 100.0), Some(-5.0)); + assert_eq!(typed("1e-3+1", 0.0), Some(1.001)); + assert_eq!(typed("abc", 1.0), None); +} diff --git a/crates/ui-egui/src/ui_kit/file_view.rs b/crates/ui-egui/src/ui_kit/file_view.rs index 57fafc3ad5..6ed0182125 100644 --- a/crates/ui-egui/src/ui_kit/file_view.rs +++ b/crates/ui-egui/src/ui_kit/file_view.rs @@ -1,5 +1,8 @@ //! File/start presentation; callers retain commands, caches and recovery ownership. -use super::{metrics::dimension, text::paint_text}; +use super::{ + metrics::dimension, + text::{paint_text, paint_text_aligned}, +}; use crate::theme::Tokens; use egui::{Align2, FontId, InnerResponse, Rect, Response, Sense, Stroke, TextureId, Ui, Vec2, pos2, vec2}; @@ -301,7 +304,15 @@ impl FileView { t.form_ink, false, ); - paint_text(ui, Rect::from_min_max(pos2(left, half), content.max), row.path, FontId::monospace(t.font_body), t.file_dim, false); + paint_text_aligned( + ui, + Rect::from_min_max(pos2(left, half), content.max), + row.path, + FontId::monospace(t.font_body), + t.file_dim, + false, + true, + ); } else { let when_width = t.space_24 * 5.0; let workspace_width = t.form_label_width * 2.0; @@ -318,13 +329,14 @@ impl FileView { t.form_ink, false, ); - paint_text( + paint_text_aligned( ui, Rect::from_min_max(pos2(path_left, content.top()), pos2((when_left - t.space_14).max(path_left), content.bottom())), row.path, FontId::monospace(t.font_body), t.file_dim, false, + true, ); paint_text( ui, diff --git a/crates/ui-egui/src/ui_kit/gallery.rs b/crates/ui-egui/src/ui_kit/gallery.rs index a9d2dbbefa..4c32a2d099 100644 --- a/crates/ui-egui/src/ui_kit/gallery.rs +++ b/crates/ui-egui/src/ui_kit/gallery.rs @@ -123,7 +123,7 @@ const ELEMENTS: &[Element] = &[ }); navigation_tile(ui, "grid", "Grid", "style", false); row(ui, "Position", |ui| { - segmented(ui, &mut state.selected, &["Left", "Centre", "Right"], Default::default()); + segmented(ui, &mut state.selected, &["Left", "Center", "Right"], Default::default()); }); checkbox(ui, "Show navigation bar", &mut state.checked); button(ui, "Reset to default", Button::Normal); @@ -271,7 +271,7 @@ const ELEMENTS: &[Element] = &[ let actions = [ StageAction { icon: Some("chain"), label: "Appearance Chain", enabled: true, primary: false, reason: None }, StageAction { icon: Some("reorder"), label: "Replace All", enabled: true, primary: false, reason: None }, - StageAction { icon: Some("star"), label: "Favourite Look", enabled: true, primary: true, reason: None }, + StageAction { icon: Some("star"), label: "Favorite Look", enabled: true, primary: true, reason: None }, StageAction { icon: Some("suppress"), label: "Hide", enabled: true, primary: false, reason: None }, ]; selection_actions(ui, "Boss", &actions); @@ -695,7 +695,7 @@ const ELEMENTS: &[Element] = &[ }); }), element!("file_action", |ui, _| { - FileView::action_tile(ui, "gallery:file:new", ui.available_width(), "new", "New design", "Millimetres · opens in Design", true); + FileView::action_tile(ui, "gallery:file:new", ui.available_width(), "new", "New design", "Millimeters · opens in Design", true); }), element!("file_recovery_banner", |ui, _| { FileView::recovery_banner( @@ -981,6 +981,12 @@ const ELEMENTS: &[Element] = &[ removable_header(ui, "Width"); }); }), + element!("sort_header", |ui, _| { + ui.allocate_ui(vec2(140.0, row_height()), |ui| { + ui.set_min_size(vec2(140.0, row_height())); + sort_header(ui, "Name", &mut Sort::Ascending); + }); + }), element!("plus_header", |ui, _| { ui.allocate_ui(vec2(140.0, row_height()), |ui| { ui.set_min_size(vec2(140.0, row_height())); @@ -1177,6 +1183,9 @@ const ELEMENTS: &[Element] = &[ element!("scalar_field", |ui, _| { scalar_field(ui, &mut 0.5, 0.0..=1.0); }), + element!("track_number", |ui, _| { + track_number(ui, &mut 1350.0, 0.5, 1); + }), element!("length_field", |ui, _| { length_field(ui, &mut 12.7, "in", 2); }), @@ -1325,6 +1334,13 @@ const ELEMENTS: &[Element] = &[ dropdown(ui, "gallery", &mut s.selected, &["One", "Two"]); }); }), + element!("dropdown_previews", "dialog:row:Gallery", Some("dialog:Gallery"), |ui, s| { + row(ui, "Gallery", |ui| { + dropdown_previews(ui, "gallery_previews", &mut s.selected, &["One", "Two"], |ui, index| { + ui.label(if index == 0 { "One" } else { "Two" }); + }); + }); + }), element!("dropdown_picked", "dialog:row:Gallery", Some("dialog:Gallery"), |ui, s| { row(ui, "Gallery", |ui| { dropdown_picked(ui, "gallery_picked", &mut s.selected, &["One", "Two"]); @@ -1415,6 +1431,21 @@ const ELEMENTS: &[Element] = &[ element!("wide_primary_button", |ui, _| { wide_primary_button(ui, "Finish Sketch"); }), + element!("persistent_button_shape", "gallery:persistent_button", Some("gallery:persistent_button"), |ui, _| { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(t.form_button_height * 3.0, t.form_button_height * 2.0), egui::Sense::click()); + ui.painter().add(persistent_button_shape(ui, &response, rect)); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "Selected", crate::fonts::semibold(t.font_label), t.selected_ink); + scenario::publish_target("gallery:persistent_button", rect); + }), + element!("native_button_focus", "gallery:native_button_focus", Some("gallery:native_button_focus"), |ui, _| { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(t.form_button_height * 3.0, t.form_button_height), egui::Sense::click()); + response.request_focus(); + ui.painter().rect_filled(rect, t.radius_sm, t.control_fill); + ui.painter().add(native_button_focus(ui, &response, rect)); + scenario::publish_target("gallery:native_button_focus", rect); + }), element!("button", "dialog:row:Gallery", Some("dialog:button:Gallery:Add"), |ui, _| { row(ui, "Gallery", |ui| { button(ui, "Add", Button::Normal); diff --git a/crates/ui-egui/src/ui_kit/layout.rs b/crates/ui-egui/src/ui_kit/layout.rs index 5e22169dff..f598e3c266 100644 --- a/crates/ui-egui/src/ui_kit/layout.rs +++ b/crates/ui-egui/src/ui_kit/layout.rs @@ -222,9 +222,21 @@ pub(crate) fn choice_row(ui: &mut Ui, label: &str, id: &str, selected: &mut usiz pub(super) fn sized_row(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce(&mut Ui) -> R) -> R { let mut passthrough = false; if crate::prefs_search::querying(ui) { - crate::prefs_search::set_control(ui, text); + // A row with no label has no words to match; its controls supply them (#9265). + let unlabelled = text.is_empty(); + if unlabelled { + crate::prefs_search::set_unlabelled_control(ui); + } else { + crate::prefs_search::set_control(ui, text); + } if !crate::prefs_search::visible(ui, &[text]) { + if unlabelled { + crate::prefs_search::push_unlabelled(ui); + } let result = crate::prefs_search::run_hidden(ui, text, control); + if unlabelled { + crate::prefs_search::pop_unlabelled(ui); + } crate::prefs_search::recheck(ui, &[text]); return result; } diff --git a/crates/ui-egui/src/ui_kit/menu.rs b/crates/ui-egui/src/ui_kit/menu.rs index d7479e1068..c512bf262d 100644 --- a/crates/ui-egui/src/ui_kit/menu.rs +++ b/crates/ui-egui/src/ui_kit/menu.rs @@ -34,7 +34,11 @@ impl Menu { Self::button_at(ui, |ui, _| item.show(ui), add, true) } - fn button_with(ui: &mut Ui, button: impl FnOnce(&mut Ui, bool) -> Response, add: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse> { + pub(crate) fn button_with( + ui: &mut Ui, + button: impl FnOnce(&mut Ui, bool) -> Response, + add: impl FnOnce(&mut Ui) -> R, + ) -> egui::InnerResponse> { Self::button_at(ui, button, add, false) } diff --git a/crates/ui-egui/src/ui_kit/mod.rs b/crates/ui-egui/src/ui_kit/mod.rs index 6a10498d16..78800da043 100644 --- a/crates/ui-egui/src/ui_kit/mod.rs +++ b/crates/ui-egui/src/ui_kit/mod.rs @@ -59,8 +59,8 @@ pub(crate) use confirm::{Confirm, Tone}; pub use container::{Container, DockEdges, Edge, Surface}; pub(crate) use disclosure::*; pub(crate) use drag::*; -pub use fields::cell_editor; pub(crate) use fields::*; +pub use fields::{cell_editor, wide_cell_editor}; pub(crate) use file_view::{FileView, RecentRow, RecoveryMessage, SampleCard}; pub(crate) use framing::*; pub(crate) use history::*; @@ -94,6 +94,8 @@ pub(crate) use sphere::{SphereTile, sphere_cursor, sphere_grid, sphere_grid_drag pub(crate) use stages::{StageAction, StageBar}; pub(crate) use status::*; pub(crate) use table::*; +#[cfg(test)] +pub(crate) use text::ellipsize_middle; pub(crate) use text_menu::menu_open as text_menu_open; pub(crate) use tree::*; pub(crate) use value_focus::*; diff --git a/crates/ui-egui/src/ui_kit/panel.rs b/crates/ui-egui/src/ui_kit/panel.rs index 7a211281de..0917a0abba 100644 --- a/crates/ui-egui/src/ui_kit/panel.rs +++ b/crates/ui-egui/src/ui_kit/panel.rs @@ -652,7 +652,7 @@ impl<'a> Panel<'a> { viewport.left() + width, (viewport.right() - if gap > 0.0 { Tokens::get().space_8 } else { 0.0 }).max(viewport.left() + width), ), - crate::frame::Side::Left => self.anchor.x.clamp(viewport.left(), viewport.right() - width), + crate::frame::Side::Left => crate::shell::within(self.anchor.x, viewport.left(), viewport.right() - width), }; let t = Tokens::get(); let chrome = t.form_header_height + t.form_footer_height + t.form_gap_y * 2.0 + self.frame.total_margin().sum().y + gap; @@ -1036,10 +1036,13 @@ impl<'a> Window<'a> { if self.body_padding { let height = (dimension(ui.ctx().content_rect().height()) - t.form_header_height - t.form_footer_height - t.space_24 * 2.0).max(0.0); + // Offer the body all the room the screen leaves, not just the height the card + // had last frame: a card first laid out on a tiny page (a browser canvas not + // sized yet) would otherwise keep a body of no height, its buttons hidden (#9472). egui::ScrollArea::vertical() .id_salt("window_body") .max_height(height) - .min_scrolled_height(0.0) + .min_scrolled_height(height) .auto_shrink([false, true]) .show(ui, body); } else { @@ -1117,7 +1120,8 @@ impl<'a> Window<'a> { /// The same bounded card frame, either movable or an input-blocking modal. pub(super) fn surface(&self, ctx: &egui::Context, modal: bool, add: impl FnOnce(&mut Ui) -> (bool, super::Action)) -> (bool, super::Action) { let frame = if self.page_chrome { - super::navigation_popup_frame().inner_margin(0).fill(Tokens::get().dialog_body.gamma_multiply(0.97)) + // A system window covers what is behind it, like every other window (#9078). + super::navigation_popup_frame().inner_margin(0).fill(Tokens::get().dialog_body) } else { Panel::card_frame(ctx) }; diff --git a/crates/ui-egui/src/ui_kit/panel_tests.rs b/crates/ui-egui/src/ui_kit/panel_tests.rs index 1615425e03..616c86d1b2 100644 --- a/crates/ui-egui/src/ui_kit/panel_tests.rs +++ b/crates/ui-egui/src/ui_kit/panel_tests.rs @@ -592,6 +592,74 @@ fn window_pages_have_shared_metrics_callbacks_and_a_pinned_left_footer_action() crate::theme::set_dark(previous); } +#[test] +fn page_window_is_as_opaque_as_every_other_window_in_both_themes() { + // #9078: the Preferences window let the ribbon's labels show through its title bar and search field. + fn window_fill(pages: bool) -> egui::Color32 { + let ctx = crate::fonts::test_context(); + let list = [WindowPage { + group: None, + icon: "settings", + label: "ui.general", + handle: "test:page:general", + scroll: true, + draw: |ui, _: &mut ()| { + text(ui, "General", Text::Inline); + }, + }]; + let mut selected = 0; + let mut output = None; + for _ in 0..3 { + scenario::clear_handles(); + output = Some(crate::test_support::frame( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 800.0))), + focused: true, + ..Default::default() + }, + |ui| { + let window = Window::new(ui.id().with("opaque"), "settings", "Preferences"); + if pages { + window.cards(3.0).show_pages(ui.ctx(), &mut selected, &list, &mut (), |_| {}); + } else { + window.show(ui.ctx(), true, |ui| { + text(ui, "Body", Text::Inline); + }); + } + }, + )); + } + let window = scenario::rect_of("dialog:window").expect("window rect published"); + let mut fill = None; + for clipped in output.iter().flat_map(|o| &o.shapes) { + crate::test_support::each_shape(&clipped.shape, &mut |shape| { + if let egui::Shape::Rect(r) = shape + && fill.is_none() + && r.blur_width == 0.0 + && (r.rect.min - window.min).abs().max_elem() <= 0.5 + && (r.rect.max - window.max).abs().max_elem() <= 0.5 + { + fill = Some(r.fill); + } + }); + } + fill.expect("the window frame is painted at the window's rect") + } + let previous = crate::theme::is_dark(); + for dark in [false, true] { + crate::theme::set_dark(dark); + let t = Tokens::get(); + assert_eq!(t.dialog_body.a(), 255, "{dark}: the window token itself is opaque"); + let plain = window_fill(false); + assert_eq!(plain, t.dialog_body, "{dark}: a plain window is the opaque dialog body"); + let page = window_fill(true); + assert_eq!(page.a(), 255, "{dark}: the Preferences page window lets nothing show through"); + assert_eq!(page, plain, "{dark}: the page window has the same fill as every other window"); + } + crate::theme::set_dark(previous); +} + #[test] fn confirm_routes_enter_escape_selected_answers_and_blocks_background_clicks() { for (key, valid, selected, expected) in [ @@ -884,6 +952,58 @@ fn required_platform_window_keeps_recovery_pending_until_an_explicit_choice() { assert!(scenario::rect_of("dialog:footer").is_none()); } +/// #9472: a card first laid out while the page is still tiny (a browser canvas not sized yet) +/// grows its body once the page has its size, so the choice stays visible on every page size and +/// scale. The browser's recovery card and the desktop one (with a footer) share this body. +#[test] +fn window_body_buttons_stay_visible_after_the_page_grows_from_nothing() { + for desktop in [false, true] { + for ppp in [1.0, 1.5, 2.0] { + for size in [egui::vec2(2992.0, 1692.0), egui::vec2(1496.0, 846.0), egui::vec2(800.0, 600.0), egui::vec2(320.0, 200.0)] { + let ctx = crate::fonts::test_context(); + let mut buttons = Vec::new(); + for page in [egui::Vec2::ZERO, egui::vec2(1.0, 1.0), egui::vec2(300.0, 150.0), size, size, size, size] { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, page); + let viewport = egui::ViewportInfo { native_pixels_per_point: Some(ppp), ..Default::default() }; + let input = egui::RawInput { + screen_rect: Some(screen), + focused: true, + viewports: std::iter::once((egui::ViewportId::ROOT, viewport)).collect(), + ..Default::default() + }; + buttons.clear(); + crate::test_support::frame(&ctx, input, |ui| { + let window = Window::new(egui::Id::new("sc_browser_recovery"), "restore", "Recover unsaved work?"); + let body = |ui: &mut Ui| { + ui.label("“Bracket” had unsaved changes when this page last closed."); + ui.horizontal(|ui| { + for label in ["Recover", "Discard"] { + let rect = button(ui, label, Button::Normal).rect; + buttons.push((label, rect, ui.clip_rect())); + } + }); + }; + if desktop { + window.actions("", "Later", None).show_with_left(ui.ctx(), true, body, |_| {}); + } else { + window.required().show_body(ui.ctx(), body); + } + }); + } + let screen = ctx.content_rect(); + assert_eq!(buttons.len(), 2, "desktop {desktop}, {size:?} at {ppp}"); + for (label, rect, clip) in &buttons { + assert!(rect.height() > 0.0 && rect.width() > 0.0, "{label}: desktop {desktop}, {size:?} at {ppp}: {rect:?}"); + assert!( + clip.contains_rect(*rect) && screen.contains_rect(*rect), + "{label} hidden: desktop {desktop}, {size:?} at {ppp}: {rect:?} clipped to {clip:?} on {screen:?}" + ); + } + } + } + } +} + #[test] fn adjoining_compact_frames_share_one_stroke_and_only_exposed_corners_round() { use crate::ui_kit::DockEdges; @@ -1143,3 +1263,21 @@ fn bevel_lighting_reverses_for_fields_and_pressed_buttons_without_changing_geome let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| bevel.paint(ui.painter(), tiny, 0, false, true)); assert!(!output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { .. }))); } + +/// A left-anchored panel in a view that came out inside out keeps an anchor instead of +/// panicking in `f32::clamp`, as the layout beside it did in #9492. +#[test] +fn a_left_panel_in_an_inside_out_view_keeps_an_anchor() { + let ctx = crate::fonts::test_context(); + let inside_out = egui::Rect::from_min_max(egui::pos2(264.1875, 40.0), egui::pos2(159.0, 600.0)); + let panel = super::Panel::new( + "Panel", + egui::Id::new("inside_out_panel"), + egui::pos2(200.0, 80.0), + inside_out, + crate::frame::Side::Left, + egui::Frame::NONE, + ); + let anchor = panel.bounded_anchor(&ctx); + assert!(anchor.x.is_finite() && anchor.y.is_finite(), "{anchor:?}"); +} diff --git a/crates/ui-egui/src/ui_kit/preferences_figures.rs b/crates/ui-egui/src/ui_kit/preferences_figures.rs index acc9cea990..2c7ce82b2d 100644 --- a/crates/ui-egui/src/ui_kit/preferences_figures.rs +++ b/crates/ui-egui/src/ui_kit/preferences_figures.rs @@ -89,7 +89,7 @@ pub(crate) fn paint_orbit(p: &Painter, rect: Rect, pivot: usize) { p.line_segment([centre, c.at(x - 11.0, y + 5.5)], grey); } if pivot == 1 { - // A restrained screen-space crosshair makes "View centre" distinct from origin. + // A restrained screen-space crosshair makes "View center" distinct from origin. let guide = Stroke::new(c.width(1.0), t.form_dim.gamma_multiply(0.7)); for (a, b) in [((x - 9.0, y), (x - 5.0, y)), ((x + 5.0, y), (x + 9.0, y)), ((x, y - 9.0), (x, y - 5.0))] { p.line_segment([c.at(a.0, a.1), c.at(b.0, b.1)], guide); diff --git a/crates/ui-egui/src/ui_kit/table.rs b/crates/ui-egui/src/ui_kit/table.rs index 674be3f105..7568b02bfe 100644 --- a/crates/ui-egui/src/ui_kit/table.rs +++ b/crates/ui-egui/src/ui_kit/table.rs @@ -308,6 +308,101 @@ fn header_label(ui: &mut Ui, label: &str) { .on_hover_text(crate::i18n::tr_label(label)); } +/// How a table orders its rows by one column. `Listed` keeps the order the owner gave them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) enum Sort { + #[default] + Listed, + Ascending, + Descending, +} + +impl Sort { + /// A click on the header steps ascending, descending, then back to the listed order. + pub(crate) fn next(self) -> Self { + match self { + Sort::Listed => Sort::Ascending, + Sort::Ascending => Sort::Descending, + Sort::Descending => Sort::Listed, + } + } + + /// Order `items` by `key` in natural order (stable: equal names keep their listed order). + pub(crate) fn apply(self, items: &mut [T], key: impl Fn(&T) -> &str) { + match self { + Sort::Listed => {} + Sort::Ascending => items.sort_by(|a, b| natural_cmp(key(a), key(b))), + Sort::Descending => items.sort_by(|a, b| natural_cmp(key(b), key(a))), + } + } +} + +/// Natural order for names: runs of digits compare by value ("d2" before "d10"), letters +/// ignore case ("Alpha" between "a" and "b"); exact ties fall back to the plain comparison. +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering; + let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable()); + while let (Some(&p), Some(&q)) = (x.peek(), y.peek()) { + let order = if p.is_ascii_digit() && q.is_ascii_digit() { + let run = |it: &mut std::iter::Peekable>| { + let mut digits = String::new(); + while let Some(c) = it.next_if(char::is_ascii_digit) { + digits.push(c); + } + digits + }; + let (m, n) = (run(&mut x), run(&mut y)); + let (m_value, n_value) = (m.trim_start_matches('0'), n.trim_start_matches('0')); + // Compare the values by length then digits, so no run can overflow an integer; + // "01" sorts after "1". + m_value.len().cmp(&n_value.len()).then_with(|| m_value.cmp(n_value)).then_with(|| m.len().cmp(&n.len())) + } else { + x.next(); + y.next(); + p.to_lowercase().cmp(q.to_lowercase()) + }; + if order != Ordering::Equal { + return order; + } + } + x.next().is_some().cmp(&y.next().is_some()).then_with(|| a.cmp(b)) +} + +/// A header that sorts the table by its column. Clicking steps through [`Sort::next`]; the +/// chevron shows the current direction (up for ascending), and none when rows are as listed. +/// Tokens: control_radius, hover, space_4/14, font_small, form_dim/primary. +pub(crate) fn sort_header(ui: &mut Ui, label: &str, sort: &mut Sort) -> Response { + let t = Tokens::get(); + let rect = ui.max_rect(); + let response = ui.allocate_rect(rect, egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled(rect, t.control_radius, t.hover); + } + let text = crate::i18n::tr_label(label); + let font = crate::fonts::semibold(t.font_small); + let width = ui.painter().layout_no_wrap(text.to_owned(), font.clone(), t.form_dim).size().x.ceil(); + let text_rect = egui::Rect::from_min_max(rect.min, egui::pos2((rect.left() + width).min(rect.right() - t.space_14 - t.space_4), rect.bottom())); + super::text::paint_text(ui, text_rect, text, font, t.form_dim, false); + let icon = + egui::Rect::from_center_size(egui::pos2(text_rect.right() + t.space_4 + t.space_14 * 0.5, rect.center().y), vec2(t.space_14, t.space_14)); + match *sort { + Sort::Ascending => crate::icons::paint_ink_flipped(ui.painter(), icon, "chevron", t.form_primary), + Sort::Descending => crate::icons::paint_ink(ui.painter(), icon, "chevron", t.form_primary), + Sort::Listed if response.hovered() => crate::icons::paint_ink(ui.painter(), icon, "chevron", t.form_dim.gamma_multiply(0.5)), + Sort::Listed => {} + } + if response.clicked() { + *sort = sort.next(); + } + let tip = match *sort { + Sort::Listed => "Sort A to Z", + Sort::Ascending => "Sort Z to A", + Sort::Descending => "Back to the original order", + }; + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); + response.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(crate::i18n::tr_label(tip)) +} + /// Column removal appears inside the hovered header only. Keeps the former × button handle. pub(crate) fn removable_header(ui: &mut Ui, label: &str) -> bool { let t = Tokens::get(); diff --git a/crates/ui-egui/src/ui_kit/table_tests.rs b/crates/ui-egui/src/ui_kit/table_tests.rs index 81093035b6..4579cd84ce 100644 --- a/crates/ui-egui/src/ui_kit/table_tests.rs +++ b/crates/ui-egui/src/ui_kit/table_tests.rs @@ -348,3 +348,19 @@ fn name_list_gives_the_name_the_width_the_other_cells_leave() { assert_eq!(scenario::count_of("dialog:name:b.step:cut"), Some(0.0)); } } + +/// #8563: names sort in natural order: numbers by value, letters without regard to case. +#[test] +fn natural_order_compares_numbers_by_value_and_ignores_case() { + let mut names = vec!["d10", "width", "d2", "Alpha", "d1", "alpha", "Beta", "d02", "x", "x1", "p99999999999999999999999", "p100"]; + Sort::Ascending.apply(&mut names, |s| *s); + assert_eq!(names, ["Alpha", "alpha", "Beta", "d1", "d2", "d02", "d10", "p100", "p99999999999999999999999", "width", "x", "x1"]); + Sort::Descending.apply(&mut names, |s| *s); + assert_eq!(names, ["x1", "x", "width", "p99999999999999999999999", "p100", "d10", "d02", "d2", "d1", "Beta", "alpha", "Alpha"]); + let mut listed = vec!["d10", "d2", "d1"]; + Sort::Listed.apply(&mut listed, |s| *s); + assert_eq!(listed, ["d10", "d2", "d1"], "the listed order is left alone"); + assert_eq!(natural_cmp("", ""), std::cmp::Ordering::Equal); + assert_eq!(natural_cmp("", "a"), std::cmp::Ordering::Less); + assert_eq!([Sort::Listed.next(), Sort::Ascending.next(), Sort::Descending.next()], [Sort::Ascending, Sort::Descending, Sort::Listed]); +} diff --git a/crates/ui-egui/src/ui_kit/text.rs b/crates/ui-egui/src/ui_kit/text.rs index 483a2a0fff..d58b63952a 100644 --- a/crates/ui-egui/src/ui_kit/text.rs +++ b/crates/ui-egui/src/ui_kit/text.rs @@ -2,15 +2,55 @@ use crate::theme::Tokens; use egui::Ui; +/// Shorten `text` with an ellipsis in the middle so both ends stay visible (#9081). +pub(crate) fn ellipsize_middle(ui: &Ui, text: &str, font: egui::FontId, max_width: f32) -> String { + if !(max_width.is_finite() && max_width > 0.0) { + return String::new(); + } + let width = |s: &str| ui.fonts_mut(|fonts| fonts.layout_no_wrap(s.to_owned(), font.clone(), ui.visuals().text_color()).size().x); + if width(text) <= max_width + 0.5 { + return text.to_owned(); + } + let chars: Vec = text.chars().collect(); + if chars.len() < 4 { + return "…".into(); + } + let mut best = "…".to_string(); + let mut lo = 1usize; + let mut hi = chars.len() / 2; + while lo <= hi { + let keep = (lo + hi) / 2; + let left: String = chars.iter().take(keep).collect(); + let right: String = chars.iter().skip(chars.len() - keep).collect(); + let candidate = format!("{left}…{right}"); + if width(&candidate) <= max_width + 0.5 { + best = candidate; + lo = keep + 1; + } else if keep == 0 { + break; + } else { + hi = keep - 1; + } + } + best +} + /// Paint one pixel-aligned text run with the requested interface weight. /// Clipped, device-pixel-snapped weighted text. Tokens: `font_section_tracking; caller supplies font/ink tokens`. pub(super) fn paint_text(ui: &Ui, rect: egui::Rect, text: &str, font: egui::FontId, ink: egui::Color32, spaced: bool) { + paint_text_aligned(ui, rect, text, font, ink, spaced, false); +} + +/// Like [`paint_text`], but with `middle` an ellipsis sits in the middle so a path's file name +/// stays visible (#9081). +pub(super) fn paint_text_aligned(ui: &Ui, rect: egui::Rect, text: &str, font: egui::FontId, ink: egui::Color32, spaced: bool, middle: bool) { let size = font.size; - let mut format = egui::TextFormat::simple(font, ink); + let mut format = egui::TextFormat::simple(font.clone(), ink); if spaced { format.extra_letter_spacing = size * Tokens::get().font_section_tracking; } - let mut job = egui::text::LayoutJob::single_section(text.to_string(), format); + let shown = if middle { ellipsize_middle(ui, text, font, rect.width().max(0.0)) } else { text.to_owned() }; + let mut job = egui::text::LayoutJob::single_section(shown, format); job.wrap.max_width = rect.width().max(0.0); job.wrap.max_rows = 1; let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); diff --git a/crates/ui-egui/src/ui_kit/tree.rs b/crates/ui-egui/src/ui_kit/tree.rs index 731abd856a..f89b2061bb 100644 --- a/crates/ui-egui/src/ui_kit/tree.rs +++ b/crates/ui-egui/src/ui_kit/tree.rs @@ -24,6 +24,8 @@ pub(crate) struct Row<'a> { pub(crate) fold: Option, /// Some(visible) for rows with an eye. pub(crate) eye: Option, + /// The eye between the fold arrow and the radio, as before #8718, instead of at the row's end. + pub(crate) eye_left: bool, pub(crate) icon: &'a str, pub(crate) label: &'a str, /// The label is a name someone gave (a body, sketch, component, …): it is shown as typed, @@ -107,6 +109,12 @@ pub(crate) struct RowResp { pub(crate) resp: Option, /// The press began on the fold arrow, eye or radio (not a drag of the row). pub(crate) on_control: bool, + /// Centres of the fold arrow, eye and radio, for rows that publish them under other handles. + pub(crate) fold_at: Option, + pub(crate) eye_at: Option, + pub(crate) radio_at: Option, + /// Where the label starts. + pub(crate) label_left: Option, pub(crate) actions: Vec, } @@ -153,6 +161,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { } let mut x = r.left() + if row.compact { t.space_8 + row.depth as f32 * t.space_14 } else { 2.0 + row.depth as f32 * INDENT }; let cy = r.top() + row_height / 2.0; + // Where the row's depth puts it, whatever controls it has. + crate::scenario::publish_handle(&format!("indent:{}", row.label), pos2(x, cy)); let action_width = if row.compact { t.space_16 } else { t.form_close_size }; let action_left = (r.right() - if row.compact { t.space_8 } else { t.space_4 } - action_width * row.actions.len() as f32).max(r.left()); for (index, action) in row.actions.iter().enumerate() { @@ -186,11 +196,23 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // padding, the eye, the radio, or the row itself), so a click on the arrow never also // selects the row or starts a drag. let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom())); - let eye_r = Rect::from_center_size( - pos2(if row.compact { action_left - t.space_6 } else { x + 14.0 + 8.0 }, cy), - vec2(t.space_16 + t.space_3, r.height()), - ); - let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(t.space_12 + t.space_3, r.height())); + // At the row's end the eyes form one column, clear of a floating scroll bar laid over the rows. + let eye_end = !row.compact && !row.eye_left; + let scroll = &ui.spacing().scroll; + let bar = if scroll.floating { (scroll.bar_width + scroll.bar_outer_margin - scroll.floating_allocated_width).max(0.0) } else { 0.0 }; + let eye_width = t.space_16 + t.space_3; + let eye_x = if row.compact { + action_left - t.space_6 + } else if eye_end { + action_left.min(r.right() - bar) - eye_width / 2.0 + } else { + x + 14.0 + 8.0 + }; + let eye_r = Rect::from_center_size(pos2(eye_x, cy), vec2(eye_width, r.height())); + // After the fold arrow: the eye's slot, or a gap that keeps the arrow and the radio apart. + let slot = if eye_end { t.space_10 } else { 18.0 }; + 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 at = |p: Pos2| -> u8 { @@ -213,6 +235,7 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { if let Some(open) = row.fold { crate::ui_kit::disclosure(ui.painter(), pos2(x + 6.0, cy), open, if hover_on == 1 { t.text } else { t.text_dim }); out.fold = resp.clicked() && click_on == 1; + out.fold_at = Some(fold_r.center()); crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center()); } x += if row.compact { t.space_16 } else { 14.0 }; @@ -224,10 +247,11 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { 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; 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()); } if !row.compact { - x += 18.0; + x += slot; } // Activation radio (components). if let Some(active) = row.radio { @@ -237,6 +261,7 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { ui.painter().circle_filled(rc, 2.8, t.accent); } out.radio = resp.clicked() && click_on == 3; + out.radio_at = Some(radio_r.center()); x += 15.0; } if !row.icon.is_empty() && !(row.compact && row.swatch.is_some()) { @@ -268,7 +293,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // The label stops before the badges and the card's edge; a longer name ends in an ellipsis // and its full text is in the tooltip. let badges_width = row.badges.len() as f32 * 15.0; - let mut right = if row.compact && row.eye.is_some() { eye_r.left() - t.space_4 } else { action_left } - badges_width; + let end = if (row.compact || eye_end) && row.eye.is_some() { eye_r.left() - t.space_4 } else { action_left }; + let mut right = end - badges_width; if let Some(status) = row.status { right -= t.space_16; let sr = Rect::from_center_size(pos2(right + t.space_8, cy), vec2(t.space_14, t.space_14)); @@ -315,7 +341,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let corners = [inside.left_top(), inside.right_top(), inside.right_bottom(), inside.left_bottom(), inside.left_top()]; ui.painter().add(egui::Shape::dashed_line(&corners, Stroke::new(1.2, t.text_dim), 4.0, 3.0)); } - let mut bx = (x + lw + 6.0 + name_action_width).min(r.right() - 16.0); + out.label_left = Some(x); + let mut bx = (x + lw + 6.0 + name_action_width).min(if eye_end && row.eye.is_some() { end } else { r.right() } - 16.0); for b in row.badges { let br = Rect::from_center_size(pos2(bx + 6.0, cy), vec2(t.space_12, t.space_12)); badge(ui.painter(), br, b, t.text_dim); @@ -346,6 +373,13 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { }; let state = row.kind_tip.map(crate::i18n::tr).or(row.tip); let resp = match (tip, state) { + // The eye at the row's end shows its tip there, not under the name. + (Some(t), _) if hover_on == 2 && eye_end => { + let mut at_eye = resp.clone(); + (at_eye.rect, at_eye.interact_rect) = (eye_r, eye_r); + at_eye.on_hover_text(t); + resp + } (Some(t), _) => resp.on_hover_text(t), (None, Some(state)) if elided => resp.on_hover_text(format!("{label_display}\n{state}")), (None, Some(state)) => resp.on_hover_text(state), diff --git a/crates/ui-egui/src/ui_kit/value_focus.rs b/crates/ui-egui/src/ui_kit/value_focus.rs index 1d5754ed3a..684917bf27 100644 --- a/crates/ui-egui/src/ui_kit/value_focus.rs +++ b/crates/ui-egui/src/ui_kit/value_focus.rs @@ -10,6 +10,11 @@ pub(crate) fn value_fields(ctx: &egui::Context, draw: impl FnOnce() -> bool) -> (enter, fields) } +/// The expression editor the form drew last so far, in [`value_fields`]. +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)) +} + /// 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 7f033329e4..1788fb6cca 100644 --- a/crates/ui-egui/src/ui_kit/viewport_status.rs +++ b/crates/ui-egui/src/ui_kit/viewport_status.rs @@ -35,11 +35,40 @@ pub(crate) struct StatusItem { #[derive(Default)] pub(crate) struct StatusPresentation { pub open_key: Option, + focus_ids: Vec, + summary_popup: Option, + summary_members: Vec, +} + +impl StatusPresentation { + /// 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 { + 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(); + self.open_key = None; + self.summary_members = members; + } + } + + pub(crate) fn owns_focus(&self, ctx: &egui::Context) -> bool { + ctx.memory(|memory| memory.focused().is_some_and(|id| self.focus_ids.contains(&id))) + } } #[derive(Debug, PartialEq, Eq)] pub(crate) enum StatusAction { Clear(String), + ClearAll, Edit(String), } @@ -109,6 +138,9 @@ pub(crate) fn viewport_status_item(ui: &mut Ui, item: &StatusItem, state: &mut S let text = RichText::new(&item.setting).monospace().size(t.font_small).color(t.text_dim); let response = if item.editable { super::button(ui, text, Button::Ghost) } else { ui.label(text) }; scenario::publish_target(&target(item, "setting"), response.rect); + if item.editable { + state.focus_ids.push(response.id); + } if item.editable && response.clicked() { action = Some(StatusAction::Edit(item.id.clone())); } @@ -116,19 +148,24 @@ pub(crate) fn viewport_status_item(ui: &mut Ui, item: &StatusItem, state: &mut S if item.has_key { let open = state.open_key.as_deref() == Some(&item.id); let label = crate::i18n::format("ui.viewport_status_key", &[("name", &item.name)]); - if icon_action(ui, item, "key", "chevron", &label, &label, open).clicked() { + let response = icon_action(ui, item, "key", "chevron", &label, &label, open); + state.focus_ids.push(response.id); + if response.clicked() { state.open_key = if open { None } else { Some(item.id.clone()) }; } } let label = crate::i18n::format("ui.viewport_status_clear", &[("name", &item.name)]); let hint = crate::i18n::format("ui.viewport_status_clear_hint", &[("name", &item.name)]); - if icon_action(ui, item, "close", "close", &label, &hint, false).clicked() { + let response = icon_action(ui, item, "close", "close", &label, &hint, false); + state.focus_ids.push(response.id); + if response.clicked() { action = Some(StatusAction::Clear(item.id.clone())); } }); }) .response; let focus = ui.interact(response.rect, Id::new(target(item, "item")), egui::Sense::focusable_noninteractive()); + state.focus_ids.push(focus.id); focus.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), format!("{} {}", item.name, item.setting))); if focus.has_focus() { ui.painter().rect_stroke(response.rect.expand(t.space_3), frame.corner_radius, ui.visuals().selection.stroke, egui::StrokeKind::Outside); @@ -145,6 +182,7 @@ pub(crate) fn viewport_status_row( state: &mut StatusPresentation, mut key_contents: impl FnMut(&mut Ui, &str), ) -> Vec { + state.focus_ids.clear(); if items.is_empty() { state.open_key = None; return Vec::new(); @@ -179,11 +217,214 @@ pub(crate) fn viewport_status_row( scenario::publish_count("viewport_status:count", items.len() as f64); if let Some((anchor, id)) = key_anchor { let card = Container::new(ctx, Surface::Popup); - let result = card.area(Id::new("viewport_status_key")).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)); - }); + // 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); } actions } + +/// Count folding is independent of viewport geometry. Newest is supplied by Session chronology. +pub(crate) fn viewport_status_summary( + ctx: &egui::Context, + free: Rect, + items: &[StatusItem], + newest: &str, + state: &mut StatusPresentation, + mut key_contents: impl FnMut(&mut Ui, &str), +) -> Vec { + let Some(latest) = items.iter().find(|item| item.id == newest) else { return Vec::new() }; + state.focus_ids.clear(); + if !items.iter().any(|item| item.has_key && state.open_key.as_deref() == Some(&item.id)) { + state.open_key = None; + } + let t = Tokens::get(); + let mut actions = Vec::new(); + let container = Container::new(ctx, Surface::ViewportStatus); + let was_open = state.summary_popup.is_some_and(|id| egui::Popup::is_id_open(ctx, id)); + let row = container + .area(Id::new("viewport_status_summary")) + .movable(false) + .pivot(egui::Align2::CENTER_TOP) + .fixed_pos(egui::pos2(free.center().x, free.top() + t.space_8)) + .show(ctx, |ui| { + let (outline, disc) = match latest.kind { + StatusKind::Mode => (t.accent, t.accent_soft), + StatusKind::Result => (t.border, t.text_dim.gamma_multiply(0.1)), + StatusKind::Attention => (t.warning, t.warning.gamma_multiply(0.12)), + }; + 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| { + let mut button_id = None; + let mut summary_popup = None; + let menu = super::Menu::button_with( + ui, + |ui, _| { + let extra = crate::i18n::format("ui.viewport_status_summary_more", &[("count", &items.len().saturating_sub(1).to_string())]); + let name = ui.painter().layout_no_wrap(latest.name.clone(), crate::fonts::semibold(t.font_label), t.text); + let count = ui.painter().layout_no_wrap(extra, egui::FontId::monospace(t.font_small), t.text_dim); + let attention = items.iter().any(|item| item.kind == StatusKind::Attention); + let width = name.size().x + count.size().x + t.space_24 * 2.0 + t.space_8 * 4.0 + if attention { t.space_12 } else { 0.0 }; + let (rect, _) = ui.allocate_exact_size(vec2(width, t.control_height.max(ui.spacing().interact_size.y)), egui::Sense::hover()); + let response = super::button_at(ui, "viewport_status_summary_button", rect, "", Button::Ghost); + if response.clicked() { + response.request_focus(); + } + let popup_id = egui::Popup::default_response_id(&response); + summary_popup = Some(popup_id); + button_id = Some(response.id); + if response.has_focus() { + ui.painter().rect_stroke(rect, t.control_radius, ui.visuals().selection.stroke, egui::StrokeKind::Outside); + } + let mut x = rect.left() + t.space_8; + let icon = + Rect::from_center_size(egui::pos2(x + t.space_12, rect.center().y), vec2(t.space_24 - t.space_4, t.space_24 - t.space_4)); + ui.painter().circle_filled(icon.center(), icon.width() / 2.0, disc); + crate::icons::paint_icon(ui.painter(), icon.shrink(t.space_3), latest.kind.icon(), 1.0); + x += t.space_24 + t.space_8; + ui.painter().galley(egui::pos2(x, rect.center().y - name.size().y / 2.0), name.clone(), t.text); + x += name.size().x + t.space_8; + ui.painter().galley(egui::pos2(x, rect.center().y - count.size().y / 2.0), count.clone(), t.text_dim); + x += count.size().x + t.space_8; + if attention { + let dot = egui::pos2(x + t.space_3, rect.center().y); + ui.painter().circle_filled(dot, t.space_3, t.warning); + scenario::publish_target("viewport_status:summary:attention", Rect::from_center_size(dot, vec2(t.space_6, t.space_6))); + x += t.space_12; + } + let chevron = Rect::from_center_size(egui::pos2(x + t.space_12, rect.center().y), vec2(t.space_16, t.space_16)); + if egui::Popup::is_id_open(ctx, popup_id) { + crate::icons::paint_ink_flipped(ui.painter(), chevron, "chevron", t.text_dim); + } else { + crate::icons::paint_icon(ui.painter(), chevron, "chevron", 1.0); + } + let label = crate::i18n::format( + "ui.viewport_status_summary_label", + &[("name", &latest.name), ("count", &items.len().saturating_sub(1).to_string())], + ); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label.as_str())); + scenario::publish_target("viewport_status:summary", rect); + scenario::publish_target(&target(latest, "summary"), rect); + response + }, + |ui| { + super::Menu::heading(ui, crate::i18n::tr("ui.viewport_status_summary_heading")); + for item in items { + ui.push_id(&item.id, |ui| { + ui.horizontal(|ui| { + let (icon, _) = ui.allocate_exact_size(vec2(t.space_24, t.space_24), egui::Sense::hover()); + let disc = match item.kind { + StatusKind::Mode => t.accent_soft, + StatusKind::Result => t.text_dim.gamma_multiply(0.1), + StatusKind::Attention => t.warning.gamma_multiply(0.12), + }; + ui.painter().circle_filled(icon.center(), (icon.width() - t.space_4) / 2.0, disc); + crate::icons::paint_icon(ui.painter(), icon.shrink(t.space_4), item.kind.icon(), 1.0); + scenario::publish_target(&target(item, "icon"), icon); + let name = ui.label(RichText::new(&item.name).semibold().size(t.font_label)); + scenario::publish_target(&target(item, "name"), name.rect); + let setting = RichText::new(&item.setting).monospace().size(t.font_small).color(t.text_dim); + if item.editable { + let response = super::button(ui, setting, Button::Ghost); + scenario::publish_target(&target(item, "setting"), response.rect); + state.focus_ids.push(response.id); + if response.clicked() { + actions.push(StatusAction::Edit(item.id.clone())); + ui.close(); + } + } else { + let response = ui.label(setting); + scenario::publish_target(&target(item, "setting"), response.rect); + } + if item.has_key { + let label = crate::i18n::format("ui.viewport_status_key", &[("name", &item.name)]); + let response = + icon_action(ui, item, "key", "chevron", &label, &label, state.open_key.as_deref() == Some(&item.id)); + state.focus_ids.push(response.id); + if response.clicked() { + state.open_key = Some(item.id.clone()); + ui.close(); + } + } + let label = crate::i18n::format("ui.viewport_status_clear", &[("name", &item.name)]); + let hint = crate::i18n::format("ui.viewport_status_clear_hint", &[("name", &item.name)]); + let response = icon_action(ui, item, "close", "close", &label, &hint, false); + state.focus_ids.push(response.id); + if response.clicked() { + actions.push(StatusAction::Clear(item.id.clone())); + ui.close(); + } + }); + }); + } + super::Menu::separator(ui); + let label = crate::i18n::tr("ui.viewport_status_summary_clear"); + let mut footer = super::MenuItem::new("viewport_status_clear_all", label, ""); + // Display hint only. Native popup Escape closes the list without dispatch. + footer.shortcut = "Esc"; + let response = footer.show(ui); + state.focus_ids.push(response.id); + if response.clicked() { + actions.push(StatusAction::ClearAll); + ui.close(); + } + }, + ); + state.summary_popup = summary_popup; + state.focus_ids.extend(button_id); + if was_open && !summary_popup.is_some_and(|id| egui::Popup::is_id_open(ctx, id)) && actions.is_empty() { + menu.response.request_focus(); + } + if menu.response.clicked() { + state.open_key = None; + } + }); + }); + 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); + } + 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)); + } + 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)); + }); + } +} diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index fc302f532c..9f27e25c8b 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -728,10 +728,21 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { } } -/// Check now (Preferences): ignores the daily interval, not GitHub's rate limit. +/// Check now (Help or Preferences): show progress and the result, ignoring the daily interval, +/// but not GitHub's rate limit. A check already in flight serves the manual request too. pub fn check_now(app: &mut SolveApp, ctx: &egui::Context) { + app.updater.open = true; + // Keep a download or a completed installation on screen without starting another check. + if app.updater.job.is_some() || app.updater.installed.as_ref().is_some_and(Result::is_ok) || !available(app) { + return; + } + if let Some((_, manual)) = &mut app.updater.rx { + *manual = true; + app.updater.last_manual = unix_now(); + return; + } let now = unix_now(); - if app.updater.rx.is_some() || now - app.updater.last_manual < MANUAL_COOLDOWN_S || !available(app) { + if now - app.updater.last_manual < MANUAL_COOLDOWN_S { return; } if now < app.preferences.update.rate_limited_until { @@ -773,9 +784,9 @@ fn start(app: &mut SolveApp, ctx: &egui::Context, manual: bool) { let _ = tx.send(fetch()); ctx.request_repaint(); }); - if spawned.is_err() { + if let Err(error) = spawned { app.updater.rx = None; - app.updater.status = Status::Idle; + app.updater.status = if manual { Status::Failed(error.to_string()) } else { Status::Idle }; } } #[cfg(target_arch = "wasm32")] @@ -801,10 +812,10 @@ fn poll(app: &mut SolveApp) { let newer = is_newer(&release.tag, current_version()); app.updater.status = if newer { Status::Idle } else { Status::UpToDate }; if newer && manual { - // Asked for: show it even if it was skipped or put off. + // Asked for: offer it even if it was skipped or put off. check_now already + // opened the window; don't reopen it if the user closed it while checking. app.preferences.update.skipped.clear(); app.preferences.update.remind_after = 0.0; - app.updater.open = true; } if app.updater.job.is_none() && app.updater.release.as_ref().is_none_or(|known| !is_newer(&known.tag, &release.tag)) { app.updater.release = Some(release); @@ -889,6 +900,12 @@ fn poll_install(app: &mut SolveApp) { Err(TryRecvError::Disconnected) => Err("the update stopped".into()), }; app.updater.job = None; + // Cancel already cleared the job in the UI; ignore a late "cancelled" result so Offer returns + // without an error banner (#8908). + if matches!(&result, Err(e) if e == "cancelled" || e.contains("cancelled")) { + app.updater.installed = None; + return; + } match &result { Ok(install::Outcome::Installer(program, args)) => { // The installer replaces this program: start it once SolidCraft has closed. On @@ -948,10 +965,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if !app.updater.open { return; } - let Some(release) = app.updater.release.clone() else { - app.updater.open = false; + if app.updater.job.is_none() + && !app.updater.installed.as_ref().is_some_and(Result::is_ok) + && (matches!(app.updater.status, Status::Checking | Status::Failed(_)) || offered_or_newer(app).is_none()) + { + show_check_status(app, ctx); return; - }; + } + let Some(release) = app.updater.release.clone() else { return }; let kind = app.updater.kind().clone(); let install = install_method(app); let managed = app.updater.managed_by().map(str::to_string); @@ -1084,15 +1105,46 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Some("restart") => restart(app), Some("close") => app.updater.open = false, Some("cancel_download") => { - if let Some((_, progress)) = &app.updater.job { + // Drop the job from the UI at once so Cancel leaves "Downloading" immediately; the + // worker still sees the flag and stops at the next feed/checksum/chunk check (#8908). + if let Some((_, progress)) = app.updater.job.take() { progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); } + app.updater.installed = None; } _ if closed => app.updater.open = false, _ => {} } } +/// Manual checks also have a visible result when no update can be offered. +fn show_check_status(app: &mut SolveApp, ctx: &egui::Context) { + let (message, warning) = if !available(app) { + (crate::i18n::tr("update.check_unavailable").to_string(), false) + } else { + match &app.updater.status { + Status::Checking => (crate::i18n::tr("update.checking").to_string(), false), + Status::UpToDate => (crate::i18n::tr("update.up_to_date").to_string(), false), + Status::Failed(error) => (crate::i18n::format("update.failed", &[("error", error)]), true), + Status::Idle => (crate::i18n::tr("update.check_unavailable").to_string(), false), + } + }; + let id = egui::Id::new("sc_update"); + let (closed, action) = ui_kit::Window::new(id, "update", crate::i18n::tr("update.check_for_updates")) + .cards(1.7) + .actions("", crate::i18n::tr("ui.close"), None) + .show(ctx, true, |ui| { + ui_kit::text(ui, &message, ui_kit::Text::Report("check_status", warning)); + ui_kit::text(ui, &crate::i18n::format("update.you_have", &[("version", current_version())]), ui_kit::Text::Note("have")); + }); + if let Some(area) = ctx.memory(|m| m.area_rect(id)) { + crate::scenario::publish_rect("update:window", area); + } + if closed || action == ui_kit::Action::Cancel { + app.updater.open = false; + } +} + /// What the Update card shows below the notes. #[derive(Clone, Copy, PartialEq, Eq)] enum State { diff --git a/crates/ui-egui/src/update/install.rs b/crates/ui-egui/src/update/install.rs index 4eee689168..602a67e5d8 100644 --- a/crates/ui-egui/src/update/install.rs +++ b/crates/ui-egui/src/update/install.rs @@ -172,6 +172,19 @@ pub struct Net { pub feed: Option, } +/// GET `url` unless Cancel was pressed; check again after the response so a cancel during the +/// wait still ends the install without starting the package download (#8908). +fn get_unless_cancelled(net: &Net, progress: &Progress, url: &str) -> Result, FetchError> { + if progress.cancel.load(Ordering::Relaxed) { + return Err(FetchError::Failed("cancelled".into())); + } + let body = (net.get)(url)?; + if progress.cancel.load(Ordering::Relaxed) { + return Err(FetchError::Failed("cancelled".into())); + } + Ok(body) +} + /// Refresh the feed, then download, verify and apply the newest release for `method`, falling /// back to `release` when the check fails. Runs on a background thread. `staging` holds downloads /// that are not written next to their target (the `.msi`, the `.dmg`). @@ -184,6 +197,11 @@ pub fn run( staging: &Path, progress: &Progress, ) -> Result { + // Honour Cancel before each network wait: feed refresh and checksum fetches used to run + // without looking at the flag, so Cancel looked dead until the body download started (#8908). + if progress.cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } // The release shown may be stale by now: install the newest one the feed names, or the one // shown when the feed can't be read. let (release, fallback) = match super::fetch_latest(net.feed.as_deref(), Some(&net.get)) { @@ -200,7 +218,7 @@ pub fn run( return Err(format!("{} is too large ({} bytes)", asset.name, asset.size)); } let sums_url = release.assets.iter().find(|a| a.name == SUMS).map(|a| a.url.clone()).ok_or_else(|| format!("this release has no {SUMS}"))?; - let sums = (net.get)(&sums_url).map_err(|e| format!("{SUMS}: {e}"))?; + let sums = get_unless_cancelled(net, progress, &sums_url).map_err(|e| format!("{SUMS}: {e}"))?; if let Some(key) = PUBLIC_KEY { let sig_url = release .assets @@ -208,7 +226,7 @@ pub fn run( .find(|a| a.name == SUMS_SIG) .map(|a| a.url.clone()) .ok_or_else(|| format!("this release isn't signed (no {SUMS_SIG})"))?; - let sig = (net.get)(&sig_url).map_err(|e| format!("{SUMS_SIG}: {e}"))?; + let sig = get_unless_cancelled(net, progress, &sig_url).map_err(|e| format!("{SUMS_SIG}: {e}"))?; verify_signature(&sums, &String::from_utf8_lossy(&sig), key, &release.tag)?; } let sums = String::from_utf8_lossy(&sums); diff --git a/crates/ui-egui/src/update/install_tests.rs b/crates/ui-egui/src/update/install_tests.rs index 2fa03c85c5..10df00cbf0 100644 --- a/crates/ui-egui/src/update/install_tests.rs +++ b/crates/ui-egui/src/update/install_tests.rs @@ -129,12 +129,41 @@ fn an_appimage_is_replaced_only_when_it_verifies() { let bare = release_with(&[(name, new.len())]); assert!(run(&bare, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap_err().contains(SUMS)); - // Cancelled: nothing changes. + // Cancelled before any network work: nothing changes. let good = format!("{} {name}\n", hex(&new)); let n = net(vec![(name.into(), new.clone()), (SUMS.into(), good.clone().into_bytes())]); let p = Progress::default(); p.cancel.store(true, std::sync::atomic::Ordering::Relaxed); - assert!(run(&r, &kind, &m, "x86_64", &n, &dir, &p).is_err()); + let err = run(&r, &kind, &m, "x86_64", &n, &dir, &p).unwrap_err(); + assert!(err.contains("cancelled"), "{err}"); + assert_eq!(std::fs::read(&target).unwrap(), b"old version"); + + // Cancel during the checksum GET: package download never starts (#8908). + let progress = Arc::new(Progress::default()); + let flag = progress.clone(); + let downloaded = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let hit = downloaded.clone(); + let files = Arc::new(vec![(name.into(), new.clone()), (SUMS.into(), good.clone().into_bytes())]); + let f2 = files.clone(); + let find = |files: &[(String, Vec)], url: &str| files.iter().find(|(n, _)| url == format!("{BASE}{n}")).map(|(_, b)| b.clone()); + let n = Net { + feed: None, + get: Arc::new(move |url: &str| { + let body = find(&files, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))?; + if url.ends_with(SUMS) { + flag.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + } + Ok(body) + }), + download: Arc::new(move |url: &str, to: &Path, _| { + hit.store(true, std::sync::atomic::Ordering::Relaxed); + let body = find(&f2, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))?; + std::fs::write(to, body).map_err(|e| FetchError::Failed(e.to_string())) + }), + }; + let err = run(&r, &kind, &m, "x86_64", &n, &dir, &progress).unwrap_err(); + assert!(err.contains("cancelled"), "{err}"); + assert!(!downloaded.load(std::sync::atomic::Ordering::Relaxed), "package must not start after Cancel"); assert_eq!(std::fs::read(&target).unwrap(), b"old version"); // Verified: renamed over the old one, executable, restart offered. diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index e192fdaeea..0f2606a465 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -358,6 +358,149 @@ fn turning_off_automatic_checks_stops_the_launch_check() { assert!(h.app.updater.release.is_none()); } +#[test] +fn help_menu_checks_with_automatic_checks_off_and_offers_installation() { + let dir = scratch(); + let (feed, version) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + h.app.preferences.check_updates = false; + h.app.preferences.update.skipped = format!("v{version}"); + h.app.preferences.update.remind_after = unix_now() + REMIND_DELAY_S; + h.app.updater.feed = Some(feed); + h.app.updater.kind = Some(InstallKind::MacApp("/Applications/SolidCraft.app".into())); + h.app.updater.managed = Some(None); + h.app.services.http_get = Some(Arc::new(|_| panic!("the check must use the test feed"))); + h.app.services.http_download = Some(Arc::new(|_, _, _| panic!("offering an update must not install it"))); + h.app.help.menu = true; + h.frames(2); + let [x, y] = h.widget("Check for updates").expect("the Help menu has an update check"); + h.call("ui.click", json!({"x": x, "y": y})); + wait_for_check(&mut h); + h.frames(2); + assert!(!h.app.help.menu); + assert!(h.app.updater.open); + assert!(h.app.preferences.update.skipped.is_empty()); + assert_eq!(h.app.preferences.update.remind_after, 0.0); + assert!(h.widget(&format!("SolidCraft {version} is available")).is_some()); + assert!(h.widget("Download and open").is_some(), "the macOS installation is offered immediately"); + assert!(h.app.updater.job.is_none()); +} + +/// Hold an automatic check at its result channel so the manual request can join it without +/// a second HTTP request or a wall-clock race. +fn pending_check() -> (crate::scenario::Harness, std::sync::mpsc::Sender>) { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.check_updates = false; + h.app.updater.kind = Some(InstallKind::WindowsMsi); + h.app.services.http_get = Some(Arc::new(|_| panic!("do not start a second check"))); + let (tx, rx) = std::sync::mpsc::channel(); + h.app.updater.rx = Some((rx, false)); + h.app.updater.status = Status::Checking; + (h, tx) +} + +#[test] +fn help_check_shows_progress_and_up_to_date_then_closes() { + let (mut h, tx) = pending_check(); + h.call("ui.help", json!({"item": "updates"})); + assert!(h.app.updater.open); + assert!(h.app.updater.rx.as_ref().unwrap().1, "the in-flight automatic check becomes manual"); + assert!(h.widget("Checking…").is_some()); + let release = Release::parse( + json!({"tag_name": format!("v{}", comparable(&parse_version(current_version()).unwrap())), "assets": []}).to_string().as_bytes(), + ) + .unwrap(); + tx.send(Ok(release)).unwrap(); + h.frames(2); + assert_eq!(h.app.updater.status, Status::UpToDate); + assert!(h.widget("SolidCraft is up to date.").is_some()); + assert!(h.widget("Download").is_none() && h.widget("Update now").is_none()); + let [x, y] = h.widget("Close").unwrap(); + h.call("ui.click", json!({"x": x, "y": y})); + assert!(!h.app.updater.open); + // Reopening during the cooldown still shows the result, without another request. + h.app.updater.last_manual = unix_now(); + h.call("ui.help", json!({"item": "updates"})); + assert!(h.widget("SolidCraft is up to date.").is_some()); + assert!(h.app.updater.rx.is_none()); +} + +#[test] +fn help_check_reports_network_errors_and_rate_limits_in_the_window() { + for error in [FetchError::Failed("offline".into()), FetchError::RateLimited { retry_after_s: 3600 }] { + let (mut h, tx) = pending_check(); + h.call("ui.help", json!({"item": "updates"})); + tx.send(Err(error.clone())).unwrap(); + h.frames(2); + assert_eq!(h.app.updater.status, Status::Failed(error.to_string())); + assert!(h.widget(&format!("Couldn't check: {error}")).is_some()); + assert!(h.widget("SolidCraft is up to date.").is_none()); + if matches!(error, FetchError::RateLimited { .. }) { + h.call("ui.help", json!({"item": "updates"})); + assert!(h.app.updater.rx.is_none(), "the server's rate limit is honoured"); + assert!(matches!(h.app.updater.status, Status::Failed(_))); + } + } +} + +#[test] +fn help_check_result_follows_the_selected_language() { + let (mut h, tx) = pending_check(); + h.call("ui.prefs", json!({"language": "de"})); + h.call("ui.help", json!({"item": "updates"})); + let release = Release::parse(json!({"tag_name": current_version(), "assets": []}).to_string().as_bytes()).unwrap(); + tx.send(Ok(release)).unwrap(); + h.frames(2); + assert!(h.widget("SolidCraft ist aktuell.").is_some()); + for (language, title, message) in [ + ("fr", "Rechercher des mises à jour", "SolidCraft est à jour."), + ("ja", "アップデートを確認", "SolidCraft は最新です。"), + ("zh-TW", "檢查更新", "SolidCraft 已是最新版本。"), + ] { + h.call("ui.prefs", json!({"language": language})); + assert!(h.widget(title).is_some(), "the window title follows {language}"); + assert!(h.widget(message).is_some(), "the result follows {language}"); + assert!(h.widget("SolidCraft is up to date.").is_none()); + } +} + +#[test] +fn help_check_explains_when_checks_are_unavailable() { + let mut h = crate::scenario::Harness::new(); + h.app.updater.off = true; + h.call("ui.help", json!({"item": "updates"})); + assert!(h.widget("Update checks are not available for this installation.").is_some()); + assert!(h.widget("SolidCraft is up to date.").is_none()); + assert!(h.app.updater.rx.is_none()); +} + +#[test] +fn closing_a_manual_check_does_not_reopen_it_when_an_update_arrives() { + let (mut h, tx) = pending_check(); + h.call("ui.help", json!({"item": "updates"})); + let [x, y] = h.widget("Close").unwrap(); + h.call("ui.click", json!({"x": x, "y": y})); + assert!(!h.app.updater.open); + let release = Release::parse(br#"{"tag_name":"v999.0.0","assets":[]}"#).unwrap(); + tx.send(Ok(release)).unwrap(); + h.frames(2); + assert!(!h.app.updater.open); + assert!(offered(&h.app).is_some(), "the update remains available from the title bar"); +} + +#[test] +fn help_check_keeps_an_active_installation_on_screen() { + let (mut h, dir) = open_update_window(None); + let (_tx, rx) = std::sync::mpsc::channel(); + h.app.updater.job = Some((rx, Arc::new(install::Progress::default()))); + h.app.updater.open = false; + h.call("ui.help", json!({"item": "updates"})); + assert!(h.app.updater.open && h.app.updater.job.is_some()); + assert!(h.app.updater.rx.is_none()); + assert!(h.widget("Cancel").is_some()); + drop(dir); +} + #[test] fn the_title_bar_offers_the_new_version_and_opens_the_update_window() { let dir = scratch(); @@ -750,3 +893,48 @@ fn install_selection_refreshes_the_button_and_progress_before_completion() { assert!(h.app.updater.installed.is_none()); tx.send(()).unwrap(); } + +#[test] +fn cancel_download_leaves_downloading_immediately() { + // Cancel must clear the job from the UI at once so the card leaves "Downloading" without + // waiting for a stalled feed or checksum GET (#8908). + let dir = scratch(); + let target = dir.0.join("SolidCraft.AppImage"); + let name = asset_name(&InstallKind::AppImage(target.clone()), std::env::consts::ARCH, "999.1.0").unwrap(); + let mut h = crate::scenario::Harness::new(); + h.app.preferences.check_updates = false; + let mut shown = release(); + shown.tag = "v999.0.0".into(); + h.app.updater.release = Some(shown); + h.app.updater.kind = Some(InstallKind::AppImage(target)); + h.app.updater.open = true; + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let gate_rx = std::sync::Mutex::new(gate_rx); + h.app.services.http_get = Some(Arc::new(move |url| { + if url == LATEST_API { + return Ok(json!({"tag_name":"v999.1.0", "assets":[ + {"name": name, "browser_download_url":format!("https://github.com/tangency-labs/solidcraft/releases/download/v999.1.0/{name}")}, + {"name": install::SUMS, "browser_download_url":"https://github.com/tangency-labs/solidcraft/releases/download/v999.1.0/SHA256SUMS.txt"} + ]}).to_string().into_bytes()); + } + // Stall checksum GET until the test ends so Cancel has to work before the body download. + let _ = gate_rx.lock().unwrap().recv(); + Ok(format!("{} {name}\n", "a".repeat(64)).into_bytes()) + })); + h.app.services.http_download = Some(Arc::new(|_, _, _| Err(FetchError::Failed("download must not start".into())))); + start_install(&mut h.app, &egui::Context::default()); + assert!(h.app.updater.job.is_some()); + h.frames(2); + let cancel = h.widget("Cancel").expect("Cancel while Downloading"); + h.call("ui.click", json!({"x": cancel[0], "y": cancel[1]})); + assert!(h.app.updater.job.is_none(), "Cancel clears the job immediately"); + assert!(h.app.updater.installed.is_none()); + assert!(h.app.updater.open, "Cancel leaves the Update window open on Offer"); + h.frames(2); + assert!( + h.widget("Remind me later").is_some() || h.widget("Skip this version").is_some() || h.widget("Update now").is_some(), + "Offer returns after Cancel" + ); + assert!(h.widget("Updating to 999.1.0.").is_none()); + let _ = gate_tx.send(()); +} diff --git a/crates/ui-egui/src/view_pick.rs b/crates/ui-egui/src/view_pick.rs index af1ed79a86..2c864fc924 100644 --- a/crates/ui-egui/src/view_pick.rs +++ b/crates/ui-egui/src/view_pick.rs @@ -206,7 +206,7 @@ fn face_candidates( ) -> Vec { let mut out = Vec::new(); for (i, b) in st.bodies.iter().enumerate() { - if !app.session.body_visible(&b.name) || replaced.contains(&b.name) { + if !app.body_visible(&b.name) || replaced.contains(&b.name) { continue; } let ghosted = crate::viewport::body_faded(app, b, active_bodies); @@ -329,7 +329,7 @@ pub(crate) fn body_cut_at( ) -> Option { let walk_eps = 1e-6 * (1.0 + crate::viewport::scene_radius(app)); for (i, b) in st.bodies.iter().enumerate() { - if !app.session.body_visible(&b.name) || replaced.contains(&b.name) { + if !app.body_visible(&b.name) || replaced.contains(&b.name) { continue; } if !include_ghosted && crate::viewport::body_faded(app, b, active_bodies) { @@ -390,7 +390,7 @@ fn near_edges( let mut best_e: Option = None; let mut best_v: Option = None; for (i, b) in st.bodies.iter().enumerate() { - if !app.session.body_visible(&b.name) || replaced.contains(&b.name) { + if !app.body_visible(&b.name) || replaced.contains(&b.name) { continue; } if !include_ghosted && crate::viewport::body_faded(app, b, active_bodies) { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 07d32f0233..99940cf2f5 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -569,6 +569,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.cam.up.hash(&mut h); (app.cam.pitch > 0.0).hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.dialog.as_ref().map(|d| &d.edit_source_bodies).hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_sketches.hash(&mut h); app.session.visibility.shown_sketches.hash(&mut h); @@ -865,7 +866,7 @@ fn update_refinement(app: &mut SolveApp, ctx: &egui::Context, proj: &Proj) { if b.name != *name || b.body.is_mesh() { continue; } - let shown = app.session.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name); + let shown = app.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name); let on_screen = match screen_box(proj, bx, 0.0).filter(|_| shown) { Some(r) => r.intersects(proj.rect).then(|| f64::from(r.width().max(r.height())) * ppp), // A corner behind the eye: the view is inside or right by the box. @@ -1426,7 +1427,7 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene { let markers = app.preferences.vertex_markers; let hidden_col = c4(tk.text.gamma_multiply(0.35)); let shown: Vec<&solidcraft_engine::doc::ModelBody> = - st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name)).collect(); + st.bodies.iter().filter(|b| app.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name)).collect(); let mut arena = app.viewport.palette.lock().unwrap_or_else(|e| e.into_inner()); let shown_names: Vec<&str> = shown.iter().map(|b| b.name.as_str()).collect(); arena.sync(shown_names.iter().copied()); @@ -1619,7 +1620,7 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene { let mut kept = app.viewport.section_index.lock().map(|mut k| std::mem::take(&mut *k)).unwrap_or_default(); let mut made = std::collections::HashMap::with_capacity(kept.len()); let normal = [n.x.to_bits(), n.y.to_bits(), n.z.to_bits()]; - for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) { + for b in st.bodies.iter().filter(|b| app.body_visible(&b.name)) { let mesh = b.mesh(); let key = (std::sync::Arc::as_ptr(&mesh) as usize, normal); let index = match kept.remove(&key).filter(|(m, i)| std::sync::Arc::ptr_eq(m, &mesh) && i.normal() == n) { @@ -1672,12 +1673,14 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let st = crate::open_meshing::shown_world(app); let mut fill_budget = 131_072; let sketch_plane = solidcraft_engine::view::open_sketch_plane(s).or_else(|| previewed_sketch_plane(app)); - if sketch_plane.is_some() || (app.ui.show_grid && !active_view(app).hide_grid) { + // 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()); - // An open sketch always 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 seen edge-on. + // 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 + // seen edge-on. let (c, u, v, slant, ext, far_reach, w3): (Vec3, Vec3, Vec3, f64, f64, f64, Box [f32; 3]>) = if let Some(pl) = &sketch_plane { let (c, u, v) = solidcraft_engine::view::sketch_grid_frame(pl, app.cam.target, major); // How steeply the sketch's plane is seen: the grid reaches as far on it as the view @@ -1806,7 +1809,7 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &std::sync::Arc Vec Vec { /// Operand picking excludes idle MID selection, including while a tool is temporarily taken /// out of `app.tool` to handle its click. pub(crate) fn pick_for_tool(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { - pick_hits(app, proj, pos, false, false, false) + pick_hits(app, proj, pos, false, false, false, false) } /// Like [`pick`], but also returns points of every visible sketch: only for the 3D drawing snap, @@ -2271,11 +2275,28 @@ pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { /// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the /// ray, instead of the nearest of each kind. Each kind is nearest first. pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { - pick_hits(app, proj, pos, false, true, true) + pick_hits(app, proj, pos, false, true, true, false) +} + +/// See-through candidates in a small screen-space disk. The nine rays are fixed and bounded; +/// ordinary picking and repeat-click cycling retain their visible-only path. +pub(crate) fn pick_through(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { + if !pos.x.is_finite() || !pos.y.is_finite() || !proj.rect.contains(pos) { + return Vec::new(); + } + let mut out = Vec::new(); + for (x, y) in [(0.0, 0.0), (6.0, 0.0), (-6.0, 0.0), (0.0, 6.0), (0.0, -6.0), (4.0, 4.0), (-4.0, 4.0), (4.0, -4.0), (-4.0, -4.0)] { + for hit in pick_hits(app, proj, pos + vec2(x, y), false, true, true, true) { + if !out.iter().any(|previous| same_target(previous, &hit)) { + out.push(hit); + } + } + } + out } fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec { - let mut hits = pick_hits(app, proj, pos, other_sketch_points, false, true); + let mut hits = pick_hits(app, proj, pos, other_sketch_points, false, true, false); // A Single hole's face place snaps to the face's centre and its round edges' centres. crate::hole_place::snap_hits(app, proj, pos, &mut hits); hits @@ -2334,7 +2355,7 @@ impl Near { } } -fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool, include_midpoints: bool) -> Vec { +fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool, include_midpoints: bool, through: bool) -> Vec { let s = &app.session; let st = crate::open_meshing::shown_world(app); let bounds = body_bounds(app, &st); @@ -2360,6 +2381,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, } Some(moved) }; + let sketches = if through { st.sketches.iter().map(|s| s.feature).collect() } else { visible_sketches(app) }; let mut hits = Vec::new(); let (o, d) = proj.ray(pos); // MID is an idle sketch selection target. Tools and dialogs keep their existing picks. @@ -2393,7 +2415,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, let point_sketches: Vec = if crate::render_workspace::active(app) { Vec::new() } else if external_points { - visible_sketches(app) + sketches.clone() } else { s.active_sketch.into_iter().collect() }; @@ -2439,24 +2461,29 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, // Sketch curves: the active sketch's own before projected and other sketches' ones, then // nearest (the active sketch wins ties). let mut bestc = Near::new(all); - for sid in visible_sketches(app).into_iter().filter(|_| !crate::render_workspace::active(app)) { + for sid in sketches.clone().into_iter().filter(|_| !crate::render_workspace::active(app)) { let Some(ss) = st.sketch(sid) else { continue }; let active = s.active_sketch == Some(sid); let bonus = if active { 2.0 } else { 0.0 }; let tier = |own: bool| u8::from(!(active && own)); + // Trim / Extend / Break edit free curves; projected edges under the same click should + // yield so a drawn rectangle on a face is not refused as projected geometry (#8979). + let trim_prefers_free = app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "sketch.trim" | "sketch.extend" | "sketch.break")); for (i, c) in ss.sketch.curves.iter().enumerate() { if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) { continue; } let straight = matches!(c.kind, CurveKind::Line { .. }); - let tier = tier(c.link.is_none() || ss.sketch.is_text_curve(i)); + let own = c.link.is_none() || ss.sketch.is_text_curve(i); + let tier = tier(own); // Construction geometry lying on a regular curve yields to it (#6396); Select Other // still offers both. let helper = if c.construction || c.centerline { 0.5 } else { 0.0 }; + let linked_penalty = if trim_prefers_free && !own { 3.5 } else { 0.0 }; for part in &crate::sketch_display::curve_sample(app, sid, i, &ss.plane, proj).parts { let pts: Vec<(Pos2, Vec3)> = part.iter().filter_map(|q| proj.to_screen(*q).map(|sp| (sp, *q))).collect(); for w in pts.windows(2) { - let dd = seg_dist(pos, w[0].0, w[1].0) - bonus + helper; + let dd = seg_dist(pos, w[0].0, w[1].0) - bonus + helper + linked_penalty; if dd < 6.0 && bestc.wants((tier, dd)) { let point = ray_segment_closest(o, d, w[0].1, w[1].1); bestc.offer((tier, dd), Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight, point }); @@ -2497,7 +2524,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, // Bounds retain the state's body order. Looking each one up by name made both passes // quadratic for assemblies with many small parts. for (i, b) in st.bodies.iter().enumerate() { - if !app.session.body_visible(&b.name) { + if !through && !app.body_visible(&b.name) { continue; } // Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. @@ -2559,13 +2586,13 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, // Wireframe draws no faces, so nothing hides an edge or a vertex. `slack` is the body's own: // what counts as lying on the face in front depends on how coarsely that body is meshed. let wireframe = app.ui.visual_style == 2; - let visible = |p: Vec3, slack: f64| kept(p) && (wireframe || hidden_from.is_none_or(|t| (p - o).dot(d) <= t + slack)); + let visible = |p: Vec3, slack: f64| kept(p) && (through || wireframe || hidden_from.is_none_or(|t| (p - o).dot(d) <= t + slack)); // Vertices (edge end points) and edges, within 7 and 6 points of the cursor. let reach = crate::pick_index::reach(proj, pos, 8.0); let mut bestv = Near::new(all); let mut beste = Near::new(all); for (i, b) in st.bodies.iter().enumerate() { - if !app.session.body_visible(&b.name) { + if !through && !app.body_visible(&b.name) { continue; } let mut body_slack = slack; @@ -2649,7 +2676,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, let mut surf: Vec<(f64, Hit)> = Vec::new(); let mut bestp: Option<(f64, f64, Hit)> = None; let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new(); - for sid in visible_sketches(app) { + for sid in sketches.clone() { let Some(ss) = st.sketch(sid) else { continue }; let n = ss.plane.normal(); let den = n.dot(d); @@ -2751,7 +2778,7 @@ fn feature_parts(app: &SolveApp, id: u64, shown_only: bool) -> Vec { let st = crate::open_meshing::shown_world(app); let mut out = Vec::new(); for b in &st.bodies { - if shown_only && (!app.session.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name)) { + if shown_only && (!app.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name)) { continue; } if b.feature == id { @@ -2799,7 +2826,7 @@ fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec { .occurrence_bodies(path, true) .unwrap_or_default() .into_iter() - .filter(|name| app.session.body_visible(name)) + .filter(|name| app.body_visible(name)) .map(|name| Sel::Body { name }) .collect(), _ => grouped_selection(&app.session, item), @@ -2897,7 +2924,9 @@ fn analysis_uniform(app: &SolveApp) -> [f32; 8] { /// While Draft, Accessibility or Curvature Map analysis is on, what each of its colours means and /// the values it stands for, at the bottom left of the viewport (#973). The colours alone don't /// say, and with colour vision loss they can be hard to tell apart. -fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) { +pub(crate) type AnalysisLegend = (&'static str, Option, Vec<(Color32, String, bool)>); + +pub(crate) fn analysis_legend_data(app: &SolveApp) -> Option { use solidcraft_engine::SurfaceAnalysis as A; let t = Tokens::get(); let tr = crate::i18n::tr_label; @@ -2908,7 +2937,7 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) { let dir = |v: Vec3| format!("({:.2}, {:.2}, {:.2})", v.x, v.y, v.z); let a = &t.analysis; // (title, the direction it measures along, rows of colour, text, hatched). - let (title, along, rows): (&str, Option, Vec<(Color32, String, bool)>) = match app.session.analysis { + let (title, along, rows) = match app.session.analysis { Some(A::Draft { pull, angle }) => ( tr("Draft Analysis"), Some(format!("{} {}", tr("Pull direction"), dir(pull))), @@ -2933,11 +2962,20 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) { (col(a.curvature[2]), format!("{} ≤ {}", tr("Radius"), crate::prefs::show_mm(app, radius, 1)), false), ], ), - _ => return, + _ => return None, }; + 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; - let r = Rect::from_min_size(pos2(rect.left() + 12.0, rect.bottom() - 12.0 - h), vec2(230.0, h)); + // 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; @@ -3321,6 +3359,19 @@ fn hover_drawn(hover: &Option) -> String { } } +/// The body the current hover actually selects, after the active dialog's filter. +fn hovered_body(app: &SolveApp) -> Option { + if app.tool.is_some() { + return None; + } + let hit = app.viewport.hover.as_ref()?; + let (_, selected) = candidate(app, std::slice::from_ref(hit))?; + match selected { + Sel::Body { name } => Some(name), + _ => None, + } +} + fn highlight_geometry_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); let selected = app.highlighted(); @@ -3328,6 +3379,7 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 { (std::sync::Arc::as_ptr(&crate::open_meshing::shown_world(app)) as usize).hash(&mut h); serde_json::to_string(&selected).unwrap_or_default().hash(&mut h); hover_drawn(&app.viewport.hover).hash(&mut h); + hovered_body(app).hash(&mut h); app.viewport.hover_feature.hash(&mut h); app.viewport.hover_bodies.hash(&mut h); app.viewport.hover_plane.hash(&mut h); @@ -3368,8 +3420,11 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 { app.preferences.reference_geometry_through_bodies.hash(&mut h); app.session.visibility.hidden_origin.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); + app.dialog.as_ref().map(|d| &d.edit_source_bodies).hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_folders.hash(&mut h); + // How strongly print faces are tinted (Preferences › General › Slicer). + app.preferences.print_face_highlight.to_bits().hash(&mut h); h.finish() } @@ -3727,19 +3782,45 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { (EdgeColours::new(&mut feedback, t.hover_edge, t.hover_edge_halo), EdgeColours::new(&mut feedback, t.sel_edge, t.sel_edge_rim)); let mut hover_instances = HighlightInstances::default(); let mut selected_instances = HighlightInstances::default(); + // Print faces (#8429): tinted towards the colour section views give cut faces, and outlined, + // so the face a slicer export lays its body on shows at a glance. Drawn before the selection, + // which lights over it. + // The share of that colour is the user's (Preferences › General › Slicer, default a fifth); the + // outline takes a little over twice it, so the face reads as marked without outshouting the model. + let strength = app.preferences.print_face_highlight.clamp(0.0, 1.0); + if !product && strength > 0.0 { + let cap = t.section_cap; + let blend = |from: u8, to: u8, k: f32| (f32::from(from) + (f32::from(to) - f32::from(from)) * k).round().clamp(0.0, 255.0) as u8; + let mix = |body: u8, tint: u8| blend(body, tint, strength); + let edge = t.body_edge; + let k = (strength * 2.25).min(1.0); + let rim = Color32::from_rgb(blend(edge.r(), cap.r(), k), blend(edge.g(), cap.g(), k), blend(edge.b(), cap.b(), k)); + let outline = EdgeColours::new(&mut feedback, rim, rim); + for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name)) { + let Some(Ok(face)) = s.doc.print_face(&st, b) else { continue }; + let (r, g, bl) = body_rgb(app, &b.name); + face_tris(&mut feedback, &shown(b), face.index, [mix(r, cap.r()), mix(g, cap.g()), mix(bl, cap.b()), 255], true); + let m = drawn_mesh(app, b); + for (ei, e) in m.edges.iter().enumerate() { + if m.edge_pickable(ei) && m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| *f as usize == face.index)) { + edge_lines(&mut feedback, e, outline, 2.0, &[]); + } + } + } + } // Selected things. for x in &sel { match x { Sel::Face { body, index, .. } => { // A live preview stands in for the body: its old faces would fight with it. - if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !replaced(body)) { + if let Some(b) = st.body(body).filter(|_| app.body_visible(body) && !replaced(body)) { face_tris(&mut feedback, &shown(b), *index, c4(t.sel_face), false); hidden_face_outline(&mut feedback, &drawn(b), *index, t.sel_edge); } } Sel::Body { name } => { if let Some(b) = st.body(name) - && app.session.body_visible(name) + && app.body_visible(name) { let lit = WholeBody { selected: true, colour: c4(t.body_selected) }; if !light_whole_body(app, b, &active_bodies, lit, &body_slots, &mut feedback, &mut selected_instances) { @@ -3751,7 +3832,7 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { } Sel::Edge { body, index, point } => { if let Some(b) = st.body(body) - && app.session.body_visible(body) + && app.body_visible(body) && !moving(body) { let m = drawn(b); @@ -3825,6 +3906,8 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default(); hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() })); hovered.extend(component_hover.into_iter().flatten()); + let body_hover = hovered_body(app); + hovered.extend(body_hover.iter().map(|name| Sel::Body { name: name.clone() })); // By name, once: a whole assembly holds thousands of bodies, and scanning the list for // each of them is quadratic (as is a body lookup by name in the world). let hovered_whole: std::collections::HashSet<&str> = @@ -3843,7 +3926,7 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { selected_faces.entry(body.as_str()).or_default().insert(*index); } } - for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name)) { + for b in st.bodies.iter().filter(|b| app.body_visible(&b.name) && !app.preview.effective_replaced().contains(&b.name)) { let whole = hovered_whole.contains(b.name.as_str()); let faces: Vec = if whole { (0..b.body.face_count()).collect() } else { hovered_faces.get(b.name.as_str()).cloned().unwrap_or_default() }; @@ -3880,22 +3963,10 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { } // Hover (pre-highlight). match hover { - // Body selection: the whole body lights up. - Some(Hit::Face { body, .. }) if app.ui.pick_bodies && app.dialog.is_none() && app.tool.is_none() => { - if let Some(b) = st.body(body) { - let k = t.hover_face_lift; - let c = body_color(&s.doc, b).unwrap_or(t.body); - let hover_col = [c.r().saturating_add(k), c.g().saturating_add(k), c.b().saturating_add(k), 255]; - let lit = WholeBody { selected: false, colour: hover_col }; - if !light_whole_body(app, b, &active_bodies, lit, &body_slots, &mut feedback, &mut hover_instances) { - let m = shown(b); - let count = b.body.face_count(); - mesh_tris(&mut feedback, &m, hover_col, true, |f| (f as usize) < count); - } - } - } Some(Hit::Face { body, index, .. }) - if !replaced(body) && !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => + if body_hover.as_ref() != Some(body) + && !replaced(body) + && !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => { if let Some(b) = st.body(body) { let k = t.hover_face_lift; @@ -4268,7 +4339,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { back: proj.cam.back().to_f32(), size_px, images: crate::ref_images::gpu_images(app), - points: crate::point_clouds::gpu_points(app), + clouds: crate::point_clouds::gpu_clouds(app, ui.ctx()), analysis: analysis_uniform(app), analysis_colors: crate::gpu::analysis_colors(&t.analysis), access: crate::access::map(app), @@ -4299,7 +4370,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } let overlay_rect = crate::shell::bottom_overlay_rect(app, rect); - analysis_legend(app, &painter, overlay_rect); + if !app.preferences.viewport_analysis_status { + analysis_legend(app, &painter, overlay_rect); + } crate::frame_rate::paint(app, ui, &painter, overlay_rect); // ---- interaction ---- @@ -4441,7 +4514,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { dialog.restore_value_focus = true; } if resp.clicked() && !held && !reference_pick && !joint_handle_pointer && !app.viewport.draw_drag_handled { - if app.tool.is_some() { + if crate::browser_canvas::click(app, &proj, p) { + // Calibrate Canvas: the click sets Point A or B on the image (#9108). + } else if app.tool.is_some() { crate::tools::on_click(app, &proj, p, mods.shift); } else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { if let Some((hit, Some(sel))) = cand { @@ -4514,6 +4589,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { .collect(); let _ = app.run("select.set", json!({ "items": items, "add": add })); } + // Double-click a curve while a Mirror/Move/Copy/Scale/pattern Entities input is open: + // add the whole connected chain to that input (#8390). + if resp.double_clicked() && !held && !dim_edit && app.tool.is_none() && app.dialog.is_some() { + let _ = crate::sketch_modify_ui::double_click_entities(app); + } // Double-click an edge: select the edges that continue it smoothly (the loop). if resp.double_clicked() && !held @@ -4814,6 +4894,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } crate::preview::paint_sketches(app, &painter, &proj); crate::ref_images::show(app, ui, &painter, &proj); + crate::browser_canvas::paint_marks(app, &painter, &proj); crate::point_clouds::show(app, &painter, &proj); crate::dialogs_assembly::origins(app, &painter, &proj); if !crate::joint_handles::visible(app) { @@ -4862,9 +4943,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { axis_view(app, ui, rect); crate::view_angles::show(app, ui, rect); crate::navigation_bar_view::show(app, ui, rect); + crate::viewport_status::show(app, ui.ctx(), rect); crate::viewport_status_fixture::show(app, ui.ctx(), rect); // The notices and the agent chip first, so the 3D mouse picture can keep clear of them. - let toasts = transient_messages(app, ui, rect); + // The selection's measurement in the corner, before the notices so they keep clear of it. + let readout = quick_measure_readout(app, ui, rect); + let toasts = transient_messages(app, ui, rect, readout); if app.preferences.space_mouse_in_view && app.space_mouse.calibrating.is_none() && let Some(device) = &app.space_mouse.device @@ -4967,9 +5051,21 @@ fn agent_chip(app: &mut SolveApp, ui: &egui::Ui, view: Rect, taken: &[Rect]) -> /// How long a status message stays in the view (milliseconds). const STATUS_TOAST_MS: f64 = 6000.0; +/// The selection's measurement in the view's bottom right corner (`quick_measure`), clear of the +/// panels and cards over the view and its navigation bar. +fn quick_measure_readout(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) -> Option { + crate::quick_measure::poll(app, ui.ctx()); + let mut taken = crate::shell::covered_rects(app); + if let Some(layout) = &app.viewport.layout { + taken.extend([layout.navigation, layout.axis_view]); + } + let view = crate::shell::content_rect(app, viewport); + crate::quick_measure::show(app, ui, view, &taken) +} + /// A single visual pattern for ongoing work, the existing six-second status, the 3D mouse /// calibration offer and the stand-up note, stacked from the bottom of the free view. Returns the toasts' rects. -pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) -> Vec { +pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect, readout: Option) -> Vec { let placement_hint = app.tool.as_ref().filter(|tool| matches!(tool.cmd, "canvas.insert" | "canvas.decal")).map(|tool| crate::tools::hint(tool.cmd)); // Nothing to show or time: an idle window skips the layout. The offer is checked too, as its clock @@ -4989,6 +5085,7 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re if let Some(layout) = &app.viewport.layout { obstacles.extend([layout.navigation, layout.axis_view]); } + obstacles.extend(readout); let view = crate::shell::content_rect(app, viewport); let mut free = crate::viewport_layout::free_rect(view, &obstacles).shrink(t.space_8); if ToastPlace::current(ui.ctx()) == ToastPlace::Corner { @@ -4998,19 +5095,30 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re // (#7717): the shared toast with its buttons, which go by themselves and never block input. if let Some(text) = crate::stand_up::note_due(app, ui.ctx()) { let labels = crate::stand_up::note_labels(); - let actions = - [(labels[0].as_str(), ToastKind::Primary), (labels[1].as_str(), ToastKind::Secondary), (labels[2].as_str(), ToastKind::Dismiss)]; - if let Some((r, clicked, buttons)) = paint_toast_with(ui, free, text, ToastLead::Dot(false), &actions) { + let actions = [(labels[0].as_str(), ToastKind::Primary), (labels[1].as_str(), ToastKind::Secondary)]; + let [close, dismiss] = crate::stand_up::NOTE_CLOSE_HANDLES; + let extras = ToastExtras { + close: Some(close), + countdown: Some((crate::stand_up::note_left(app, ui.ctx()), crate::stand_up::NOTE_SECONDS)), + ..Default::default() + }; + if let Some(toast) = paint_toast_ext(ui, free, text, ToastLead::Dot(false), &actions, extras) { + let r = toast.rect; crate::scenario::publish_rect("toast:stand_up", r); - for (handle, button) in crate::stand_up::NOTE_HANDLES.iter().zip(&buttons) { + for (handle, button) in crate::stand_up::NOTE_HANDLES.iter().zip(&toast.buttons) { crate::scenario::publish_handle(handle, button.center()); } + if let Some(at) = toast.close { + crate::scenario::publish_handle(dismiss, at.center()); + } obstacles.push(r); free.max.y = r.top() - t.space_8; shown.push(r); crate::stand_up::note_keep(app, ui.ctx(), r); - if let Some(index) = clicked { + if let Some(index) = toast.clicked { crate::stand_up::note_act(app, index); + } else if toast.closed { + crate::stand_up::leave(app); } } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); @@ -5019,14 +5127,28 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re if let Some(offer) = crate::space_mouse_offer::due(app, ui.ctx()) { let labels: Vec = offer.actions.iter().map(|action| crate::i18n::tr(action.label()).to_string()).collect(); let labels: Vec<(&str, ToastKind)> = labels.iter().map(String::as_str).zip(offer.actions.iter().map(|action| action.kind())).collect(); - if let Some((r, clicked, _)) = paint_toast_with(ui, free, crate::i18n::tr("ui.3d_mouse_offer").to_string(), ToastLead::Dot(false), &labels) { + let tips: Vec<&str> = offer.actions.iter().map(|action| action.tip().map_or("", crate::i18n::tr)).collect(); + let extras = ToastExtras { + close: Some("toast:space_mouse_offer:close"), + countdown: Some((crate::space_mouse_offer::left(app, ui.ctx()), crate::space_mouse_offer::SHOW_SECONDS)), + tips: &tips, + ..Default::default() + }; + if let Some(toast) = paint_toast_ext(ui, free, crate::i18n::tr("ui.3d_mouse_offer").to_string(), ToastLead::Dot(false), &labels, extras) { + let r = toast.rect; crate::scenario::publish_rect("toast:space_mouse_offer", r); + for (index, button) in toast.buttons.iter().enumerate() { + crate::scenario::publish_rect(&format!("toast:space_mouse_offer:button:{index}"), *button); + } obstacles.push(r); free.max.y = r.top() - t.space_8; shown.push(r); crate::space_mouse_offer::keep(app, ui.ctx(), r); - if let Some(action) = clicked.and_then(|i| offer.actions.get(i)) { - crate::space_mouse_offer::act(app, ui.ctx(), &offer, *action); + // The close (×) does what the Close button did (#7971). + let action = + if toast.closed { Some(crate::space_mouse_offer::Action::Close) } else { toast.clicked.and_then(|i| offer.actions.get(i).copied()) }; + if let Some(action) = action { + crate::space_mouse_offer::act(app, ui.ctx(), &offer, action); } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } @@ -5043,8 +5165,16 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re if shown_ms < STATUS_TOAST_MS { let undo = app.status_undo; let actions = if undo { vec![(crate::i18n::tr_label("Undo"), ToastKind::Secondary)] } else { Vec::new() }; - if let Some((r, clicked, _)) = paint_toast_with(ui, free, msg, ToastLead::Dot(error), &actions) { - if undo && clicked == Some(0) { + let extras = ToastExtras { + close: Some("toast:status:close"), + countdown: Some((countdown_left(0.0, shown_ms / 1000.0, STATUS_TOAST_MS / 1000.0), STATUS_TOAST_MS / 1000.0)), + // Without Undo, a click anywhere else on it opens the run's messages. + card_hint: (!undo).then(|| crate::i18n::tr("messages.chip_hint")), + ..Default::default() + }; + if let Some(toast) = paint_toast_ext(ui, free, msg, ToastLead::Dot(error), &actions, extras) { + let r = toast.rect; + if undo && toast.clicked == Some(0) { app.status = None; app.status_undo = false; let _ = app.run("edit.undo", json!({})); @@ -5055,11 +5185,13 @@ pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Re shown.push(r); // Open the run's messages from the tokenised status toast. crate::scenario::publish_handle("status:chip", r.center()); - if !undo - && ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(crate::i18n::tr("messages.chip_hint")).clicked() - { + if toast.card_clicked { app.messages.open = true; } + if toast.closed { + app.status = None; + app.status_undo = false; + } } // One frame when it is due to go, not a frame every half second while it shows. let left_ms = STATUS_TOAST_MS - shown_ms; @@ -5112,8 +5244,9 @@ impl ToastPlace { } } -/// Every toast's width in the corner stack: room for three buttons on one row; longer text wraps. -pub(crate) const TOAST_WIDTH: f32 = 340.0; +/// Every toast's width in the corner stack: room for three buttons on one row (the 3D mouse offer's +/// Calibrate…, Use Direct Driver and Never show again, #7971); longer text wraps. +pub(crate) const TOAST_WIDTH: f32 = 360.0; /// Where the corner stack may go: the largest free rect in a `TOAST_WIDTH` column at the right of /// `view`, clear of `obstacles` (the axis view and cube at the top, the view toolbar when a narrow @@ -5156,7 +5289,7 @@ pub(crate) fn corner_free(view: Rect, obstacles: &[Rect]) -> Rect { /// What a toast's button is for, which sets its look and where it goes: the main action first /// (the accent button), then an alternative, then the quiet dismissals. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum ToastKind { Primary, Secondary, @@ -5237,6 +5370,16 @@ fn toast_action_spots_trailing(buttons: &[(f32, ToastKind)], row_width: f32, gap spots } +/// Where a toast button's drawn width is remembered: by its text and kind, which set its look. +fn toast_button_key(label: &str, kind: ToastKind) -> egui::Id { + egui::Id::new(("sc_toast_button_width", label, kind)) +} + +/// The width a toast button with this text and kind was last drawn at, if it has been drawn. +fn toast_button_width(ctx: &egui::Context, label: &str, kind: ToastKind) -> Option { + ctx.data(|data| data.get_temp::(toast_button_key(label, kind))).filter(|width| width.is_finite() && *width > 0.0) +} + /// What leads a toast's text: a dot (red for an error) or a spinner. #[derive(Clone, Copy)] enum ToastLead { @@ -5249,11 +5392,8 @@ fn paint_toast(ui: &egui::Ui, free: Rect, text: String, error: bool, computing: paint_toast_with(ui, free, text, lead, &[]).map(|(rect, _, _)| rect) } -/// The one toast painter: its text in a card at the bottom centre of `free`, led by a dot or a -/// spinner, and, below the text, a wrapping row of buttons (`actions`, kit buttons). Returns the -/// card's rect, which button was clicked, if any, and where each button is (for handles). With no actions the card is exactly the status -/// and computing toast it always was. The buttons are not live while an issue report holds CAD -/// input; none is laid outside the card, whose width is bounded by `free`. +/// [`paint_toast_ext`] with no close (×), countdown, hover texts or card click: the card, which +/// button was clicked, if any, and where each button is. fn paint_toast_with( ui: &egui::Ui, free: Rect, @@ -5261,6 +5401,111 @@ fn paint_toast_with( lead: ToastLead, actions: &[(&str, ToastKind)], ) -> Option<(Rect, Option, Vec)> { + paint_toast_ext(ui, free, text, lead, actions, ToastExtras::default()).map(|toast| (toast.rect, toast.clicked, toast.buttons)) +} + +/// What a toast has besides its text and buttons (#7971). +#[derive(Clone, Copy, Default)] +pub(crate) struct ToastExtras<'a> { + /// A close (×) in its top-right corner, published as this control-channel handle + /// (`toast::close`). Every toast the user may close has one, in place of a Close button. + pub close: Option<&'a str>, + /// For a toast that goes by itself: the share of its time left (1 to 0) and its whole time in + /// seconds, drawn as a thin ring around the × that shrinks over its time. + pub countdown: Option<(f32, f64)>, + /// Hover texts of the buttons, by index; an empty one has none. + pub tips: &'a [&'a str], + /// The whole card answers a click, under its buttons and ×, with this hover text. + pub card_hint: Option<&'a str>, +} + +/// A toast as drawn: its card, which button was clicked, if any, where each button and the × are +/// (for handles), and whether the × or the card itself was clicked. +pub(crate) struct ToastShown { + pub rect: Rect, + pub clicked: Option, + pub buttons: Vec, + pub close: Option, + pub closed: bool, + pub card_clicked: bool, +} + +/// The share of a countdown of `lifetime` seconds left at `now` when it started at `since`: 1 when +/// it starts, 0 when it is over, and 1 for times no clock gives (non-finite, or a start in the future). +pub(crate) fn countdown_left(since: f64, now: f64, lifetime: f64) -> f32 { + let left = 1.0 - (now - since) / lifetime; + if left.is_finite() { left.clamp(0.0, 1.0) as f32 } else { 1.0 } +} + +/// The countdown ring's radius around a toast's ×; the × is drawn inside it and its hit target is +/// the ring's square. +fn toast_close_radius() -> f32 { + Tokens::get().space_10 +} + +/// The points of the countdown ring: an arc of `left` of a turn round `centre`, from twelve o'clock +/// clockwise, so it shrinks back towards twelve o'clock as the time goes. Empty when nothing is left. +pub(crate) fn countdown_arc(centre: Pos2, radius: f32, left: f32) -> Vec { + let left = if left.is_finite() { left.clamp(0.0, 1.0) } else { 0.0 }; + if left <= 0.0 || !radius.is_finite() || radius <= 0.0 { + return Vec::new(); + } + // One segment per 7.5 degrees of the arc, at least one. + let segments = ((48.0 * left).ceil() as usize).max(1); + (0..=segments) + .map(|i| { + let angle = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * left * i as f32 / segments as f32; + centre + radius * vec2(angle.cos(), angle.sin()) + }) + .collect() +} + +/// A toast's close (×) at `rect`, with its countdown ring when `left` is given: a kit-drawn button +/// with an accessible name and hover text (`ui.close`), reachable by Tab and taken with Enter or Space. +fn toast_close(ui: &egui::Ui, rect: Rect, id: egui::Id, left: Option) -> egui::Response { + let t = Tokens::get(); + let response = ui.interact(rect, id, Sense::click()); + let painter = ui.painter(); + let centre = rect.center(); + let radius = rect.width().min(rect.height()) * 0.5; + let lit = response.hovered() || response.has_focus(); + if lit && ui.is_enabled() { + painter.circle_filled(centre, radius, t.hover); + } + if response.has_focus() { + painter.circle_stroke(centre, radius + t.space_2, Stroke::new(1.0, t.form_primary)); + } + if let Some(left) = left { + let arc = countdown_arc(centre, radius - 0.75, left); + if arc.len() >= 2 { + painter.add(egui::Shape::line(arc, Stroke::new(1.5, t.text_dim))); + } + } + let ink = if lit && ui.is_enabled() { t.text } else { t.text_dim }; + crate::icons::paint_ink(painter, Rect::from_center_size(centre, vec2(t.space_10, t.space_10)), "close", ink); + let label = crate::i18n::tr("ui.close"); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + if response.hovered() && ui.is_enabled() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response.on_hover_text(label) +} + +/// The one toast painter: its text in a card at the bottom centre of `free`, led by a dot or a +/// spinner, and, below the text, a wrapping row of buttons (`actions`, kit buttons), with what +/// `extras` adds (a close (×) at the top right with its countdown ring, hover texts, a click on the +/// card). Returns the card, which button was clicked, if any, and where each button is (for +/// handles). With no actions and no extras the card is exactly the status and computing toast it +/// always was. The buttons and the × are not live while an issue report holds CAD input; none is +/// laid outside the card, whose width is bounded by `free`. +fn paint_toast_ext( + ui: &egui::Ui, + free: Rect, + text: String, + lead: ToastLead, + actions: &[(&str, ToastKind)], + extras: ToastExtras<'_>, +) -> Option { // Rects no screen has are rejected too: the corner layout anchors to `free`'s right edge. if !free.is_finite() || !free.is_positive() || free.width() <= 40.0 || free.max.x.abs().max(free.max.y.abs()) > 1.0e7 { return None; @@ -5271,16 +5516,23 @@ fn paint_toast_with( // In the corner every toast is `TOAST_WIDTH` wide (or the room there is); at the centre each // is as wide as its text, up to 440 points. let wrap = if corner { (TOAST_WIDTH.min(free.width()) - 40.0).max(0.0) } else { (free.width() - 40.0).min(440.0) }; - let galley = painter.layout(text, crate::fonts::medium(t.font_label), t.text, wrap); + // The × keeps the top-right corner: the text stops short of its ring. + let radius = toast_close_radius(); + let close_room = if extras.close.is_some() { 2.0 * radius + t.space_4 + t.space_8 - 12.0 } else { 0.0 }; + let galley = painter.layout(text, crate::fonts::medium(t.font_label), t.text, (wrap - close_room).max(0.0)); // The buttons: each as wide as its text and a margin, in rows no wider than the text may be. let gap = t.space_8; let button_height = crate::ui_kit::row_height(); let row_width = if corner { wrap } else { (free.width() - 40.0).clamp(0.0, 440.0) }; + // Each button's width as the kit drew it (#7971): the same text, font and padding, measured + // from the button itself. Before a button was ever drawn, an estimate stands in for one frame. let sizes: Vec<(f32, ToastKind)> = actions .iter() .map(|(label, kind)| { - let padding = if *kind == ToastKind::Primary { t.space_16 } else { t.space_12 }; - let width = painter.layout_no_wrap((*label).to_string(), crate::fonts::medium(t.font_label), t.text).size().x + 2.0 * padding; + let width = toast_button_width(ui.ctx(), label, *kind).unwrap_or_else(|| { + let padding = if *kind == ToastKind::Primary { t.space_16 } else { t.space_12 }; + painter.layout_no_wrap((*label).to_string(), crate::fonts::medium(t.font_label), t.text).size().x + 2.0 * padding + }); (width.min(row_width), *kind) }) .collect(); @@ -5292,11 +5544,14 @@ fn paint_toast_with( let widest = spots.iter().map(|spot| spot.right()).fold(0.0, f32::max); let y = spots.iter().map(|spot| spot.top()).fold(0.0, f32::max); let actions_height = if spots.is_empty() { 0.0 } else { y + button_height + gap }; - let content = vec2(galley.size().x.max(widest), galley.size().y + actions_height); + let content = vec2((galley.size().x + close_room).max(widest), galley.size().y + actions_height); let mut size = content + vec2(40.0, 16.0); if corner { size.x = row_width + 40.0; } + if extras.close.is_some() { + size.y = size.y.max(2.0 * radius + 12.0); + } if !size.is_finite() || size.x > free.width() || size.y > free.height() { return None; } @@ -5314,31 +5569,80 @@ fn paint_toast_with( } let text_top = r.min + vec2(28.0, 8.0); painter.galley(text_top, galley.clone(), t.text); + // The card's own click goes first, so its buttons and × (added after it) take theirs. Behind an + // issue report it is dead too, so a click on the dead × doesn't fall through to it. + let blocked = crate::issue_report::cad_input_blocked(); + let card_clicked = extras.card_hint.is_some_and(|hint| { + let sense = if blocked { Sense::hover() } else { Sense::click() }; + ui.interact(r, egui::Id::new(("sc_toast_card", extras.close.unwrap_or_default())), sense).on_hover_text(hint).clicked() + }); let mut clicked = None; let mut buttons = Vec::with_capacity(actions.len()); let origin = text_top + vec2(0.0, galley.size().y + gap); for (index, ((label, kind), spot)) in actions.iter().zip(&spots).enumerate() { let at = spot.translate(origin.to_vec2()); - // A UI of its own on the viewport's layer, so the toast needs no `&mut Ui`. + // A UI of its own on the viewport's layer, so the toast needs no `&mut Ui`. It has the + // row's width, so a button is never squeezed into a spot laid out for a smaller one and + // its measured width is its own. + let room = Rect::from_min_size(at.min, vec2(row_width.max(at.width()), at.height())); let mut button_ui = egui::Ui::new( ui.ctx().clone(), egui::Id::new(("sc_toast_action", index)), - egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at).layout(egui::Layout::left_to_right(egui::Align::Center)), + egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Center)), ); button_ui.set_clip_rect(button_ui.clip_rect().intersect(r)); let response = button_ui - .add_enabled_ui(!crate::issue_report::cad_input_blocked(), |ui| match kind { + .add_enabled_ui(!blocked, |ui| match kind { ToastKind::Primary => crate::ui_kit::primary_button(ui, label), ToastKind::Secondary => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Normal), ToastKind::Dismiss => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Small), }) .inner; + let response = match extras.tips.get(index).filter(|tip| !tip.is_empty()) { + Some(tip) => response.on_hover_text(*tip), + None => response, + }; if response.clicked() { clicked = Some(index); } + // Remember the width it was drawn at; laid out at another, the toast is laid out again next frame. + let drawn = response.rect.width(); + if drawn.is_finite() && drawn > 0.0 { + if sizes.get(index).is_some_and(|(used, _)| (used - drawn).abs() > 0.5 && drawn < row_width) { + ui.ctx().request_repaint(); + } + ui.ctx().data_mut(|data| data.insert_temp(toast_button_key(label, *kind), drawn)); + } buttons.push(response.rect); } - Some((r, clicked, buttons)) + // The close (×), top right, level with the text's first line. + let mut close = None; + let mut closed = false; + if let Some(handle) = extras.close { + let line = painter.layout_no_wrap("A".into(), crate::fonts::medium(t.font_label), t.text).size().y; + let centre = pos2(r.right() - t.space_8 - radius, (text_top.y + line * 0.5).max(r.top() + 6.0 + radius)); + let at = Rect::from_center_size(centre, vec2(2.0 * radius, 2.0 * radius)); + let mut close_ui = egui::Ui::new( + ui.ctx().clone(), + egui::Id::new(("sc_toast_close_ui", handle)), + egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at), + ); + close_ui.set_clip_rect(close_ui.clip_rect().intersect(r)); + if blocked { + close_ui.disable(); + } + let left = extras.countdown.map(|(left, _)| left); + closed = toast_close(&close_ui, at, egui::Id::new(("sc_toast_close", handle)), left).clicked(); + crate::scenario::publish_handle(handle, centre); + close = Some(at); + // A frame each time the ring's end moves about a point, not one per display refresh. + if let Some((_, lifetime)) = extras.countdown { + let every = lifetime / (std::f64::consts::TAU * f64::from(radius)); + let every = if every.is_finite() { every.clamp(0.05, 1.0) } else { 1.0 }; + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(every)); + } + } + Some(ToastShown { rect: r, clicked, buttons, close, closed, card_clicked }) } /// A click without a command: select the candidate (replacing the selection, or adding / @@ -5486,8 +5790,13 @@ pub(crate) fn measurement_selection(s: &solidcraft_engine::Session, sel: &Sel) - representative } -/// What a box selects: sketch entities while sketching, otherwise what the open dialog's -/// active input takes (edges, faces or bodies), otherwise bodies. +/// What a box selects: sketch entities while sketching, otherwise what the open +/// dialog's active input takes (edges, faces, bodies or regions), otherwise the +/// bodies of the design. A design with no body (an imported SVG leaves a sketch +/// and no body) has only its regions (profiles) to select, so the marquee takes +/// those too. An input that asks for edges or faces never takes a region: the +/// regions are neither. A click takes a region of a shown sketch as well (see +/// hit_sel), but the marquee of a design that has a body takes the bodies alone. fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { use crate::selection::{BODIES, EDGES, FACES, PROFILES}; // While OK computes, the dialog's inputs hold. @@ -5555,14 +5864,20 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } } } else { - let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); + // With no dialog the marquee takes what a click takes: the bodies. A design whose only + // feature is an imported SVG has no body to pick at all, so its regions are what there is + // to select (#7058). A design that has a body keeps the old behaviour: the bodies alone. + let dialog_accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept); + let has_body = st.bodies.iter().any(|b| app.session.body_visible(&b.name)); + let accept = dialog_accept.unwrap_or(if has_body { BODIES } else { BODIES | PROFILES }); // Bulk body selection follows the active component, just like its unfaded bodies. // Keep other dialogs and Move's reference inputs free to pick across components. - let scoped_bodies = accept == BODIES && app.dialog.as_ref().is_none_or(|d| matches!(d.kind, crate::dialogs::Kind::Move { .. })); + let scoped_bodies = + dialog_accept.unwrap_or(BODIES) == BODIES && app.dialog.as_ref().is_none_or(|d| matches!(d.kind, crate::dialogs::Kind::Move { .. })); let active_bodies = app.session.active_occurrence_path.as_ref().filter(|_| scoped_bodies).and_then(|p| app.session.occurrence_bodies(p, true).ok()); for b in &st.bodies { - if !app.session.body_visible(&b.name) || (scoped_bodies && body_faded(app, b, &active_bodies)) { + if !app.body_visible(&b.name) || (scoped_bodies && body_faded(app, b, &active_bodies)) { continue; } let m = b.mesh(); @@ -5606,25 +5921,28 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { if (bx.crossing && crossing_hit) || (!bx.crossing && all_in && !m.edges.is_empty()) { out.push(Sel::Body { name: b.name.clone() }); } - } else if accept & PROFILES != 0 { - for sid in visible_sketches(app) { - let Some(ss) = st.sketch(sid) else { continue }; - for (pi, p) in ss.profiles.iter().enumerate() { - let parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point()); - let takes = if bx.crossing { - parts.iter().any(|part| bx.takes(&to2(part))) - } else { - crate::sketch_display::profile_region(app, sid, pi, &ss.plane, proj).as_ref().as_ref().is_some_and(|display| { - let points: Vec<_> = display.outer.segs.iter().map(|segment| ss.plane.to_world(segment.start())).collect(); - bx.takes(&to2(&points)) - }) - }; - if takes { - out.push(Sel::Profile { sketch: sid, index: pi }); - } + } + } + // The regions of the shown sketches. They are not bodies, and a design whose only + // feature is an imported SVG has none: the regions are what there is to select (#7058). + // They are not edges or faces either: a mask that asks for those never takes them. + if accept & PROFILES != 0 && accept & (EDGES | FACES) == 0 { + for sid in visible_sketches(app) { + let Some(ss) = st.sketch(sid) else { continue }; + for (pi, p) in ss.profiles.iter().enumerate() { + let parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point()); + let takes = if bx.crossing { + parts.iter().any(|part| bx.takes(&to2(part))) + } else { + crate::sketch_display::profile_region(app, sid, pi, &ss.plane, proj).as_ref().as_ref().is_some_and(|display| { + let points: Vec<_> = display.outer.segs.iter().map(|segment| ss.plane.to_world(segment.start())).collect(); + bx.takes(&to2(&points)) + }) + }; + if takes { + out.push(Sel::Profile { sketch: sid, index: pi }); } } - break; } } } @@ -6015,7 +6333,7 @@ pub(crate) fn refresh_layout(app: &mut SolveApp, ctx: &egui::Context) { // Focus releases dock reservations while keeping controls below the stage bar. content = rect; for card in &app.shell.cards { - content.min.y = (card.bottom() + Tokens::get().space_8).clamp(content.top(), content.bottom()); + content.min.y = crate::shell::within(card.bottom() + Tokens::get().space_8, content.top(), content.bottom()); } } // The full-height Assistant reserves control space, while rendering and picking @@ -6028,9 +6346,9 @@ pub(crate) fn refresh_layout(app: &mut SolveApp, ctx: &egui::Context) { && let Some(chat) = app.agent_chat.panel_rect.filter(|r| r.is_finite() && r.is_positive()) { if app.preferences.agent_chat_side == "left" { - content.min.x = (chat.right() + crate::shell::SHELL_GAP).clamp(content.left(), content.right()); + content.min.x = crate::shell::within(chat.right() + crate::shell::SHELL_GAP, content.left(), content.right()); } else { - content.max.x = (chat.left() - crate::shell::SHELL_GAP).clamp(content.left(), content.right()); + content.max.x = crate::shell::within(chat.left() - crate::shell::SHELL_GAP, content.left(), content.right()); } } // OK and Cancel in the view's corner (#6709) take the top right; the axis view goes below. @@ -7660,7 +7978,7 @@ mod tests { clip: [0.0; 4], cap: [0.0; 4], images: Vec::new(), - points: Vec::new(), + clouds: Vec::new(), analysis: [0.0; 8], analysis_colors: crate::gpu::analysis_colors(&Tokens::get().analysis), access: None, @@ -9060,6 +9378,31 @@ mod tests { app.session.world_state().body(name).cloned().unwrap() } + #[test] + fn body_input_hover_lights_the_whole_body_and_refreshes_for_face_inputs() { + let mut app = cylinder_app(); + app.set_origin_shown(false); + let body = app.session.world_state().bodies[0].clone(); + let hit = Hit::Face { body: body.name.clone(), index: 0, point: Vec3::ZERO }; + app.start("file.save_mesh"); + app.viewport.hover = Some(hit.clone()); + let expected = shown_mesh(&app, &body, &None).triangles.len() * 3 * crate::gpu::TRI_SIZE; + assert_eq!(hovered_body(&app), Some(body.name.clone())); + assert_eq!(build_highlight(&app).merged().tris.len(), expected); + let body_key = highlight_key(&app); + for accept in [crate::selection::FACES, crate::selection::FACES | crate::selection::BODIES] { + app.dialog.as_mut().unwrap().inputs[0].accept = accept; + assert_eq!(hovered_body(&app), None, "face inputs must keep face feedback"); + assert_ne!(highlight_key(&app), body_key, "a changed input must invalidate cached body feedback"); + assert!(build_highlight(&app).merged().tris.len() < expected); + } + app.dialog.as_mut().unwrap().inputs[0].accept = crate::selection::BODIES; + let (_, sel) = candidate(&app, std::slice::from_ref(&hit)).unwrap(); + dialog_pick(&mut app, &hit, sel); + assert!(app.highlighted().contains(&Sel::Body { name: body.name.clone() })); + assert_eq!(build_highlight(&app).merged().tris.len(), expected, "hover must not duplicate the selected body's faces"); + } + #[test] fn smooth_wall_display_preserves_wireframe_and_edge_selection() { let mut s = solidcraft_engine::Session::default(); @@ -9222,6 +9565,8 @@ mod tests { // #973: Draft, Accessibility and Curvature Map say what their colours mean; Zebra and no // analysis draw no legend. let mut h = crate::scenario::Harness::new(); + // Keep the existing corner legend available through the HUD compatibility setting. + h.app.preferences.viewport_analysis_status = false; h.app.home.open = false; h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap(); let legend = |h: &mut crate::scenario::Harness| { @@ -10492,11 +10837,15 @@ mod tests { let proj = projection(&h.app, rect); assert!(pick(&h.app, &proj, at).iter().any(|hit| matches!(hit, Hit::Face { .. })), "the drag starts on the box"); let pivot = crate::prefs::pivot_under(&h.app, &proj, at); + // Near an edge the pick snaps the pivot onto it, a few pixels from the cursor: + // the pivot keeps the pixel it was picked on, wherever that is. + let start = proj.to_screen(pivot).unwrap(); + assert!(start.distance(at) < 8.0, "perspective={perspective}: picked under the cursor: {at:?} -> {start:?}"); let before = h.app.cam; h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+150.0,"y1":at.y+60.0,"button":"middle","shift":true,"steps":20})); assert!((h.app.cam.yaw - before.yaw).abs() > 0.5, "the view turned"); let after = projection(&h.app, rect).to_screen(pivot).unwrap(); - assert!(after.distance(at) < 0.5, "perspective={perspective}: {at:?} -> {after:?}"); + assert!(after.distance(start) < 0.5, "perspective={perspective}: {start:?} -> {after:?}"); } } } @@ -10794,7 +11143,7 @@ mod tests { let keys = |app: &SolveApp| (scene_key(app), highlight_key(app)); h.call("ui.prefs", json!({"open": "Interface"})); crate::prefs::reveal_preference(&mut h, "prefs:color_vision"); - assert!(h.widget("Colour vision").is_some() && h.widget("Off").is_some() && h.widget("Deutan").is_some()); + assert!(h.widget("Color vision").is_some() && h.widget("Off").is_some() && h.widget("Deutan").is_some()); let before = keys(&h.app); h.step(&json!({"click":{"handle":"dialog:Mode:Deutan"}})).unwrap(); h.frame(); @@ -10835,6 +11184,162 @@ mod tests { let _ = std::fs::remove_file(&path); assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's"); } + + /// #7058: an imported SVG leaves a sketch with regions and no body. A marquee over it must + /// take those regions, as a click on one does: the box selection only looked for bodies, and + /// the regions of a body-less design were out of reach. + #[test] + fn a_marquee_over_an_imported_svg_sketch_selects_its_regions() { + let mut h = crate::scenario::Harness::new(); + let svg = r#""#; + let imported = h.app.run("sketch.insert_svg", json!({"text": svg})).unwrap(); + let sketch = imported["sketch"].as_u64().unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + assert!(h.app.session.world_state().bodies.is_empty(), "the import leaves a sketch, not a body"); + h.call("ui.view", json!({"view": "top"})); + let st = h.app.session.world_state(); + let ss = st.sketch(sketch).unwrap(); + assert_eq!(ss.profiles.len(), 1, "the imported square is one region"); + let (low, high) = ss.sketch.bounds().unwrap(); + let a = ss.plane.to_world(Vec2::new(low.x - 5.0, low.y - 5.0)); + let b = ss.plane.to_world(Vec2::new(high.x + 5.0, high.y + 5.0)); + h.step(&json!({"drag": [{"world": [a.x, a.y, a.z]}, {"world": [b.x, b.y, b.z]}]})).unwrap(); + assert_eq!(h.app.session.selection, vec![Sel::Profile { sketch, index: 0 }], "the marquee takes the imported region"); + } + + /// #7058: the reporter drags with Shift held to add the second region of the import to the + /// selection, so that both are extruded at once. + #[test] + fn a_shift_marquee_adds_the_other_region_of_an_imported_svg_sketch() { + let mut h = crate::scenario::Harness::new(); + let svg = r#""#; + let imported = h.app.run("sketch.insert_svg", json!({"text": svg})).unwrap(); + let sketch = imported["sketch"].as_u64().unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.call("ui.view", json!({"view": "top"})); + h.call("ui.view", json!({"view": "fit"})); + let st = h.app.session.world_state(); + let ss = st.sketch(sketch).unwrap(); + assert_eq!(ss.profiles.len(), 2, "the imported squares are two regions"); + // The world box of each region, a little outside its edges. + let mut boxes = Vec::new(); + for p in ss.profiles.iter() { + let mut low = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut high = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + for seg in p.region.outer.segs.iter() { + for q in [seg.start(), seg.end()] { + low.x = low.x.min(q.x); + low.y = low.y.min(q.y); + high.x = high.x.max(q.x); + high.y = high.y.max(q.y); + } + } + boxes.push((ss.plane.to_world(Vec2::new(low.x - 2.0, low.y - 2.0)), ss.plane.to_world(Vec2::new(high.x + 2.0, high.y + 2.0)))); + } + let (a, b) = boxes[0]; + h.step(&json!({"drag": [{"world": [a.x, a.y, a.z]}, {"world": [b.x, b.y, b.z]}]})).unwrap(); + assert_eq!(h.app.session.selection, vec![Sel::Profile { sketch, index: 0 }], "the marquee takes the first region"); + let (c, d) = boxes[1]; + // The drag runs from the box's top-left corner to its bottom-right one: a release on the + // navigator compass in the view's top-right corner drops the rubber band before it + // selects, so the second region is taken by a drag that ends over the model. + h.step(&json!({"drag": [{"world": [c.x, d.y, c.z]}, {"world": [d.x, c.y, d.z]}], "shift": true})).unwrap(); + assert_eq!( + h.app.session.selection, + vec![Sel::Profile { sketch, index: 0 }, Sel::Profile { sketch, index: 1 }], + "the shift marquee adds the second region" + ); + } + + /// #7058: the regions are what a body-less design has to select, but a design that has a body + /// keeps the old behaviour: the marquee takes the bodies, not the regions. + #[test] + fn a_marquee_in_a_design_with_a_body_takes_only_the_bodies() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap(); + let svg = r#""#; + let imported = h.app.run("sketch.insert_svg", json!({"text": svg})).unwrap(); + let sketch = imported["sketch"].as_u64().unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.settle(); + assert_eq!(h.app.session.world_state().bodies.len(), 1, "the design has a body and a sketch"); + assert!(!h.app.session.world_state().bodies[0].mesh().edges.is_empty(), "the body is meshed"); + h.call("ui.view", json!({"view": "top"})); + h.settle(); + h.call("ui.view", json!({"view": "fit"})); + h.settle(); + let st = h.app.session.world_state(); + let ss = st.sketch(sketch).unwrap(); + let (low, high) = ss.sketch.bounds().unwrap(); + // The screen box of the body and of the region: the marquee covers both. + let mut lo = [f64::INFINITY; 3]; + let mut hi = [f64::NEG_INFINITY; 3]; + for b in &st.bodies { + let m = b.mesh(); + for t in m.triangles.iter() { + if let Some(tr) = m.tri(t) { + for p in tr { + lo[0] = lo[0].min(p.x); + lo[1] = lo[1].min(p.y); + lo[2] = lo[2].min(p.z); + hi[0] = hi[0].max(p.x); + hi[1] = hi[1].max(p.y); + hi[2] = hi[2].max(p.z); + } + } + } + } + let mut points = Vec::new(); + for x in [lo[0], hi[0]] { + for y in [lo[1], hi[1]] { + for z in [lo[2], hi[2]] { + points.push(Vec3::new(x, y, z)); + } + } + } + for x in [low.x, high.x] { + for y in [low.y, high.y] { + points.push(ss.plane.to_world(Vec2::new(x, y))); + } + } + let mut screen_lo = [f64::INFINITY; 2]; + let mut screen_hi = [f64::NEG_INFINITY; 2]; + for p in &points { + let s = h.at(&json!({"world": [p.x, p.y, p.z]})).unwrap(); + screen_lo[0] = screen_lo[0].min(s[0]); + screen_lo[1] = screen_lo[1].min(s[1]); + screen_hi[0] = screen_hi[0].max(s[0]); + screen_hi[1] = screen_hi[1].max(s[1]); + } + h.step(&json!({"drag": [[screen_lo[0] - 5.0, screen_lo[1] - 5.0], [screen_hi[0] + 5.0, screen_hi[1] + 5.0]]})).unwrap(); + let taken = h.app.session.selection.clone(); + assert!(!taken.is_empty(), "the marquee takes the body it covers"); + assert!(taken.iter().all(|s| matches!(s, Sel::Body { .. })), "a design with a body takes the bodies alone, not the regions: {taken:?}"); + } + + /// #7058: a mask that asks for edges or faces never takes the regions: the regions are + /// neither. The Patch dialog's boundary input takes profiles or edges, so the marquee must + /// leave it the edges alone. + #[test] + fn a_marquee_in_a_dialog_that_asks_for_edges_takes_no_region() { + let mut h = crate::scenario::Harness::new(); + let svg = r#""#; + let imported = h.app.run("sketch.insert_svg", json!({"text": svg})).unwrap(); + let sketch = imported["sketch"].as_u64().unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.call("ui.view", json!({"view": "top"})); + // The Patch dialog takes its boundary from profiles or edges. + h.step(&json!({"start": "surface.patch"})).unwrap(); + assert!(h.app.dialog.is_some(), "the patch dialog is open"); + let st = h.app.session.world_state(); + let ss = st.sketch(sketch).unwrap(); + let (low, high) = ss.sketch.bounds().unwrap(); + let a = ss.plane.to_world(Vec2::new(low.x - 5.0, low.y - 5.0)); + let b = ss.plane.to_world(Vec2::new(high.x + 5.0, high.y + 5.0)); + h.step(&json!({"drag": [{"world": [a.x, a.y, a.z]}, {"world": [b.x, b.y, b.z]}]})).unwrap(); + 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:?}"); + } } #[cfg(test)] @@ -11216,7 +11721,7 @@ mod grid_plane_tests { ); } - /// An open sketch always has its grid, on its own plane, whatever the Grid switch and the + /// An open sketch has its grid, on its own plane, whatever the Grid switch and the /// follow-the-view preference say; outside a sketch the switch decides. #[test] fn an_open_sketch_always_shows_a_grid_on_its_plane() { @@ -11239,6 +11744,29 @@ mod grid_plane_tests { assert!(!grid(&app).is_empty()); } + /// The Sketch panel's Sketch Grid box turns the open sketch's grid off, whatever the Grid + /// switch says, in the window and in what renders and the control channel are told; on + /// again, it is back, and outside the sketch the switch decides as before (#9147). + #[test] + fn the_sketch_grid_option_turns_the_open_sketchs_grid_off() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + let grid = |app: &SolveApp| !build_sketch_scene(app, &projection(app, rect)).under.is_empty(); + for switch in [false, true] { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.show_grid = switch; + app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + assert!(grid(&app) && crate::control::window_guides(&app).grid, "switch {switch}: the sketch's grid shows"); + app.run("sketch.options", json!({"grid": false})).unwrap(); + assert!(!grid(&app), "switch {switch}: the option turned the grid off"); + assert!(!crate::control::window_guides(&app).grid, "switch {switch}"); + app.run("sketch.options", json!({"grid": true})).unwrap(); + assert!(grid(&app) && crate::control::window_guides(&app).grid, "switch {switch}: on again"); + app.run("sketch.options", json!({"grid": false})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + assert_eq!(grid(&app), switch, "outside the sketch the switch decides"); + } + } + #[test] fn imported_scene_projection_toggle_keeps_front_components_in_clip_range() { use solidcraft_engine::doc::{Document, FeatureKind, MeshData}; diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index b8b77c8cc1..a9f9923b8d 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -7,6 +7,10 @@ use solidcraft_engine::render::Camera; pub(crate) struct Layout { pub viewport: Rect, pub drawable: Rect, + /// The view without the panels (Browser, Assistant, workspace panels), but still counting + /// the floating controls as drawn over it: where a fit puts the middle of the model. The + /// compass and the navigation bar take room out of `drawable` without moving this. + pub middle: Rect, pub content: Rect, pub axis_view: Rect, pub navigation: Rect, @@ -161,13 +165,18 @@ impl Layout { Rect::NOTHING }; } + let within = if fit_in_content { content } else { viewport }; + // Only the panels decide where the middle of the view is; the controls below are drawn + // over the view and would otherwise push the model into a corner of it. + let middle = free_rect(within, &obstacles); + let middle = if middle.is_positive() && middle.is_finite() { middle } else { within }; for control in [axis_view, navigation, confirm] { if control.is_positive() { obstacles.push(control); } } - let drawable = free_rect(if fit_in_content { content } else { viewport }, &obstacles).shrink(16.0 / scale); - Self { viewport, drawable, content, axis_view, navigation, confirm } + let drawable = free_rect(within, &obstacles).shrink(16.0 / scale); + Self { viewport, drawable, middle, content, axis_view, navigation, confirm } } } @@ -211,8 +220,25 @@ pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: depth = depth.max(d.dot(b).abs()); } let h = f64::from(layout.viewport.height()); - let hh = (hx * h / f64::from(layout.drawable.width())).max(hy * h / f64::from(layout.drawable.height())).max(0.5); - let offset = layout.drawable.center() - layout.viewport.center(); + let drawable = layout.drawable; + let hh = (hx * h / f64::from(drawable.width())).max(hy * h / f64::from(drawable.height())).max(0.5); + // Home and Fit put the model in the middle of the view. `drawable` decides its size and keeps + // it clear of every control; the room it leaves over in either direction moves the model + // towards `middle`, the middle of the view, instead of the middle of the gap between the + // compass and the navigation bar, which left it low and to one side of an otherwise empty view. + let size = vec2((hx * h / hh) as f32, (hy * h / hh) as f32).min(drawable.size()); + let toward = |want: f32, lo: f32, hi: f32| if lo <= hi { want.clamp(lo, hi) } else { (lo + hi) * 0.5 }; + let at = pos2( + toward(layout.middle.center().x, drawable.left() + size.x * 0.5, drawable.right() - size.x * 0.5), + toward(layout.middle.center().y, drawable.top() + size.y * 0.5, drawable.bottom() - size.y * 0.5), + ); + let half = vec2((at.x - drawable.left()).min(drawable.right() - at.x), (at.y - drawable.top()).min(drawable.bottom() - at.y)); + let room = Rect::from_center_size(at, half * 2.0); + 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 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 }; // Offset the target so the model projects into the free rectangle in the original viewport. @@ -226,7 +252,7 @@ pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&z) - && layout.drawable.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32)) + && room.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32)) }) }); if fits { diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index c9efd7937a..ca8fe4ea7a 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -555,3 +555,47 @@ fn escape_ends_navigation_before_sketch_tools_and_does_not_count_as_a_finish_pre h.step(&json!({"key":"Escape"})).unwrap(); assert!(h.app.session.active_sketch.is_none(), "two ordinary quick Escapes still finish the sketch"); } + +/// Home and Fit put the model in the middle of the view. The floating controls take room out of +/// it, so the model is drawn smaller and kept clear of them, but the room they leave over moves it +/// towards the middle of the view rather than the middle of the gap between them; a panel moves +/// the middle itself (#9195). +#[test] +fn a_fit_centres_the_model_in_the_view_and_is_moved_only_by_panels() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(1600.0, 1000.0)); + let bounds = Aabb3 { min: Vec3::new(-50.0, -50.0, -50.0), max: Vec3::new(50.0, 50.0, 50.0) }; + let centre = bounds.center(); + let on_screen = |cam: Camera, p: Vec3| { + let radius = (bounds.diagonal() * 0.5 + centre.dist(cam.target)).max(cam.half_height() * 2.0).max(10.0); + let (x, y, _) = cam.to_screen(p, f64::from(viewport.width()), f64::from(viewport.height()), radius).unwrap(); + pos2(viewport.left() + x as f32, viewport.top() + y as f32) + }; + for style in [crate::prefs::NavigatorStyle::Axes, crate::prefs::NavigatorStyle::Cube] { + // The compass and the navigation bar, and nothing else. + let bare = layout(viewport, &[], 1.0, style, true); + let cam = fit_bounds(Camera::default(), &bounds, &bare, 0.0).expect("framed"); + for x in [bounds.min.x, bounds.max.x] { + for y in [bounds.min.y, bounds.max.y] { + for z in [bounds.min.z, bounds.max.z] { + let p = on_screen(cam, Vec3::new(x, y, z)); + assert!(bare.drawable.contains(p), "{style:?}: the model is clear of the controls: {p:?} {bare:?}"); + } + } + } + let middle = bare.middle.center(); + let gap = bare.drawable.center().distance(middle); + let off = on_screen(cam, centre).distance(middle); + assert!(off + 1.0 < gap, "{style:?}: nearer the middle of the view than the middle of the gap: {off} vs {gap}"); + // A panel down the left takes that part of the view away, so the middle moves right: the + // camera's target moves the other way, against its right vector. + let panel = Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 1000.0)); + let docked = layout(viewport, &[panel], 1.0, style, true); + let moved = fit_bounds(Camera::default(), &bounds, &docked, 0.0).expect("framed"); + let right = moved.basis().0; + assert!((moved.target - centre).dot(right) < -1.0, "{style:?}: a panel must move the model clear of it: {:?}", moved.target); + // It is drawn smaller with the controls shown than with the whole view to itself. + let empty = Layout::with_content(viewport, viewport, &[], 1.0, style, egui::Vec2::ZERO, Rect::NOTHING); + let roomy = fit_bounds(Camera::default(), &bounds, &empty, 0.0).expect("framed"); + assert!(roomy.distance <= cam.distance + 1e-9, "{style:?}: controls must not make the model larger"); + } +} diff --git a/crates/ui-egui/src/viewport_overlays_tests.rs b/crates/ui-egui/src/viewport_overlays_tests.rs index a86e874694..7208498967 100644 --- a/crates/ui-egui/src/viewport_overlays_tests.rs +++ b/crates/ui-egui/src/viewport_overlays_tests.rs @@ -582,12 +582,7 @@ fn the_shared_toast_draws_old_callers_exactly_as_before() { /// holds CAD input. #[test] fn toast_buttons_wrap_inside_the_card_and_are_dead_behind_an_issue_report() { - let labels = [ - ("Calibrate…", ToastKind::Primary), - ("Swap to Hidraw (Direct Driver)", ToastKind::Secondary), - ("Close", ToastKind::Dismiss), - ("Never show again", ToastKind::Dismiss), - ]; + let labels = [("Calibrate…", ToastKind::Primary), ("Use Direct Driver", ToastKind::Secondary), ("Never show again", ToastKind::Dismiss)]; for width in [260.0_f32, 360.0, 600.0, 1000.0] { for blocked in [false, true] { crate::issue_report::set_cad_input_blocked_for_test(blocked); @@ -632,7 +627,7 @@ fn toast_buttons_wrap_inside_the_card_and_are_dead_behind_an_issue_report() { } else { hits.sort_unstable(); hits.dedup(); - assert_eq!(hits, [0, 1, 2, 3], "width {width}: every button can be clicked inside the card"); + assert_eq!(hits, [0, 1, 2], "width {width}: every button can be clicked inside the card"); } } } @@ -680,6 +675,8 @@ fn toast_buttons_group_by_purpose_and_wrap_as_groups() { #[test] fn bottom_readouts_clear_floating_history_on_the_first_frame() { let mut h = Harness::new(); + // The corner legend remains available through the analysis HUD compatibility setting. + h.app.preferences.viewport_analysis_status = false; h.app.home.open = false; h.app.ui.layout = LayoutMode::Floating; h.app.preferences.timeline_left = false; @@ -738,6 +735,8 @@ fn continuity_legend_clears_floating_history() { #[test] fn left_history_sidebar_leaves_bottom_readouts_at_the_bottom() { let mut h = Harness::new(); + // The corner legend remains available through the analysis HUD compatibility setting. + h.app.preferences.viewport_analysis_status = false; h.app.home.open = false; h.app.ui.layout = LayoutMode::Floating; h.app.preferences.timeline_left = true; @@ -746,9 +745,18 @@ fn left_history_sidebar_leaves_bottom_readouts_at_the_bottom() { h.app.run("inspect.draft", json!({})).unwrap(); h.frame(); let viewport = h.app.viewport.layout.as_ref().unwrap().viewport; + let history = crate::scenario::rect_of("history:vertical").expect("left History"); for name in ["analysis:legend", "viewport:frame_rate"] { let readout = crate::scenario::rect_of(name).unwrap(); assert!((readout.bottom() - (viewport.bottom() - 12.0)).abs() < 0.01, "{name}: {readout:?} in {viewport:?}"); + assert!(!readout.intersects(history), "{name}: {readout:?} under History {history:?}"); + } + for command in ["inspect.accessibility", "inspect.curvature_map"] { + h.app.run(command, json!({})).unwrap(); + h.frame(); + let legend = crate::scenario::rect_of("analysis:legend").unwrap(); + assert!(!legend.intersects(history), "{command}: {legend:?} under History {history:?}"); + assert!(legend.left() >= history.right() - 0.5, "{command}: legend stays right of History"); } } @@ -1132,3 +1140,936 @@ fn selected_sketch_point_aliases_respect_owner_and_visibility() { "selection does not reveal hidden points; the origin stays visible" ); } + +#[test] +fn live_analysis_status_uses_session_settings_and_declared_clear_without_history() { + let mut h = Harness::new(); + 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()); + for (command, params, expected, key) in [ + ("inspect.zebra", json!({"stripes":14.5}), "14.5 stripes", false), + ("inspect.draft", json!({"pull":[0,0,1],"angle":7.5}), "Z · 7.5°", true), + ("inspect.environment_map", json!({}), "", false), + ("inspect.accessibility", json!({"direction":[0,0,1]}), "from Z", true), + ("inspect.curvature_map", json!({"radius":12}), "R 12.00 mm", true), + ("inspect.continuity", json!({"edges":[[5,0,0]],"samples":5}), "1 edges", false), + ] { + h.app.run(command, params).unwrap(); + let effect = h.app.session.status_items().remove(0); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + assert_eq!(item.setting, expected); + assert_eq!(item.has_key, key); + assert_eq!(item.name, crate::toolbar::persistent_button_name(&h.app, solidcraft_engine::find_command(command).unwrap())); + h.frames(4); + assert!(crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).is_some()); + if key { + assert!(crate::scenario::rect_of("analysis:legend").is_none()); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap(); + h.frames(3); + let pill = crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).unwrap(); + let card = crate::scenario::rect_of("viewport_status:card").unwrap(); + assert!(card.top() > pill.bottom()); + assert!((card.left() - pill.left()).abs() < 1.0); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.viewport_status.open_key.is_none()); + assert_eq!(h.app.session.status_items()[0].command, command); + } + h.step(&json!({"click":{"handle":format!("viewport_status:{}:close",item.id)}})).unwrap(); + assert!(h.app.session.status_items().is_empty()); + assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before); + assert!(!h.app.session.is_dirty()); + } +} + +#[test] +fn analysis_status_preference_restores_corner_legend_and_old_escape() { + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({})).unwrap(); + h.call("ui.prefs", json!({"viewport_analysis_status":false})); + h.frames(4); + assert!(crate::scenario::rect_of("analysis:legend").is_some()); + assert!(crate::scenario::rect_of("viewport_status:row").is_none()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.analysis.is_some()); + assert!(h.app.preferences.persistent_ribbon_pressed); + assert!(h.app.session.single_surface_analysis()); + h.call("ui.prefs", json!({"viewport_analysis_status":true})); + h.frames(4); + assert!(crate::scenario::rect_of("analysis:legend").is_none()); + assert!(crate::scenario::rect_of("viewport_status:row").is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.analysis.is_none()); +} + +#[test] +fn analysis_status_formats_units_and_truthful_non_axis_directions() { + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({"pull":[1,1,0],"angle":2})).unwrap(); + let effect = h.app.session.status_items().remove(0); + assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, "(0.71, 0.71, 0.00) · 2°"); + h.app.run("inspect.accessibility", json!({"direction":[0,0,-1]})).unwrap(); + let effect = h.app.session.status_items().remove(0); + assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, "from −Z"); + h.app.run("inspect.curvature_map", json!({"radius":25.4})).unwrap(); + let effect = h.app.session.status_items().remove(0); + for (units, expected) in [("mm", "R 25.40 mm"), ("in", "R 1.000 in")] { + h.app.session.doc_mut().units = units.into(); + assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, expected); + } +} + +#[test] +fn analysis_status_escape_ends_live_tool_before_effect_and_never_hidden_section() { + let mut h = Harness::new(); + h.app.preferences.viewport_section_status = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.start("inspect.continuity"); + assert!(h.app.tool.is_some()); + assert!(!h.app.session.continuity.is_empty()); + h.frames(3); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.is_none()); + assert!(!h.app.session.continuity.is_empty(), "first Escape only puts down the live tool"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.continuity.is_empty()); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY"})).unwrap(); + h.app.run("inspect.draft", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XZ"})).unwrap(); + h.frames(3); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.analysis.is_none()); + assert!(h.app.session.section.is_some(), "a later-issue effect with no presented item is ineligible"); +} + +#[test] +fn analysis_status_escape_yields_to_search_even_without_text_field_focus() { + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().remove(0); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap(); + assert!(h.app.viewport_status.open_key.is_some()); + h.app.start("ui.palette"); + h.frames(3); + h.ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(!h.app.ui.palette_open, "Search commands owns the first Escape"); + assert!(h.app.viewport_status.open_key.is_some()); + assert!(h.app.session.analysis.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(h.app.session.analysis.is_some(), "the following Escape closes only the analysis key"); +} + +#[test] +fn analysis_status_ignores_invalid_layout_bounds_without_ending_the_effect() { + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({})).unwrap(); + let before = (h.app.session.status_items(), h.app.session.revision); + for bounds in [Rect::NOTHING, Rect::from_min_max(pos2(165.0, 165.0), pos2(1.0, 1.0)), Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(10.0, 10.0))] { + crate::viewport_status::show(&mut h.app, &h.ctx, bounds); + assert_eq!((h.app.session.status_items(), h.app.session.revision), before); + } +} + +#[test] +fn section_status_formats_actual_geometry_in_design_units_without_flip_sign_changes() { + let mut h = Harness::new(); + 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"), + (json!({"plane":{"origin":[-25.4,8,2],"normal":[-3,0,0]},"hatch":false}), "X -25.40 mm", "X -1.000 in"), + (json!({"plane":{"origin":[0,0,25.4],"normal":[1e-10,0,-2]},"hatch":false}), "Z 25.40 mm", "Z 1.000 in"), + (json!({"plane":{"origin":[-12,-9,4],"normal":[3,4,0]},"hatch":false}), "tilted · -14.40 mm", "tilted · -0.5669 in"), + (json!({"plane":{"origin":[-12,-9,4],"normal":[3,4,0]},"flip":true}), "tilted · -14.40 mm · hatch", "tilted · -0.5669 in · hatch"), + ] { + h.app.session.doc_mut().units = "mm".into(); + h.app.run("inspect.section", params).unwrap(); + let effect = h.app.session.status_items().into_iter().find(|e| e.command == "inspect.section").unwrap(); + for (units, expected) in [("mm", metric), ("in", imperial)] { + h.app.session.doc_mut().units = units.into(); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + assert_eq!(item.setting, expected); + assert!(item.editable && !item.has_key); + assert_eq!(item.name, crate::toolbar::persistent_button_name(&h.app, solidcraft_engine::find_command("inspect.section").unwrap())); + } + } +} + +#[test] +fn section_status_and_analysis_keep_slots_clear_independently_and_filter_escape_by_preferences() { + let mut h = Harness::new(); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + h.app.run("inspect.draft", json!({})).unwrap(); + let before = h.app.session.status_items(); + assert_eq!(before.iter().map(|e| e.command.as_str()).collect::>(), ["inspect.draft", "inspect.section"]); + assert!(crate::viewport_status::escape(&mut h.app, false)); + assert!(h.app.session.analysis.is_none() && h.app.session.section.is_some()); + assert!(crate::viewport_status::escape(&mut h.app, true)); + assert!(h.app.session.section.is_some(), "repeat cannot cascade"); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"YZ","at":2})).unwrap(); + h.app.preferences.viewport_section_status = false; + assert!(crate::viewport_status::escape(&mut h.app, false)); + assert!(h.app.session.analysis.is_none() && h.app.session.section.is_some(), "hidden Section is ineligible"); + h.app.preferences.viewport_analysis_status = false; + assert!(!crate::viewport_status::escape(&mut h.app, false)); + h.app.preferences.viewport_section_status = true; + h.app.run("inspect.zebra", json!({})).unwrap(); + assert!(crate::viewport_status::escape(&mut h.app, false)); + assert!(h.app.session.analysis.is_some() && h.app.session.section.is_none(), "Section clears while analysis HUD is disabled"); + h.app.preferences.viewport_analysis_status = true; + h.app.run("inspect.section", json!({"plane":"XY"})).unwrap(); + h.frames(3); + let section = h.app.session.status_items().into_iter().find(|e| e.command == "inspect.section").unwrap(); + let item = crate::viewport_status::presentation(&h.app, §ion).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:close",item.id)}})).unwrap(); + assert!(h.app.session.analysis.is_some() && h.app.session.section.is_none()); +} + +#[test] +fn section_status_preserves_legacy_three_items_and_dialog_escape_ownership() { + let mut h = Harness::new(); + h.app.preferences.viewport_status_summary = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.call("ui.prefs", json!({"single_surface_analysis":false})); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.app.run("inspect.continuity", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":3})).unwrap(); + h.frames(3); + let effects = h.app.session.status_items(); + assert_eq!(effects.len(), 3); + for effect in &effects { + let item = crate::viewport_status::presentation(&h.app, effect).unwrap(); + assert!(crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).is_some()); + } + crate::dialogs::edit_section(&mut h.app); + h.frames(3); + let cut = h.app.session.section; + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.section, cut); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.section.is_none()); + assert!(h.app.session.analysis.is_some() && !h.app.session.continuity.is_empty()); +} + +#[test] +fn section_close_ends_actual_cut_while_an_unrelated_tool_owns_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.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + h.frames(3); + assert!(h.app.tool.is_some()); + let effect = h.app.session.status_items().into_iter().find(|e| e.command == "inspect.section").unwrap(); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:close",item.id)}})).unwrap(); + assert!(h.app.session.section.is_none()); + assert!(h.app.tool.is_some() && !h.app.session.continuity.is_empty()); +} + +#[test] +fn analysis_status_preferences_take_first_escape_and_release_the_next_frame() { + let mut h = Harness::new(); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.call("ui.prefs", json!({"open":"Interface"})); + h.ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(!h.app.prefs_window.open); + assert!(h.app.session.analysis.is_some(), "Preferences owns the first Escape even without text focus"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.analysis.is_none(), "the next idle frame may end the effect"); +} + +#[test] +fn analysis_status_escape_owner_survives_egui_layout_repasses() { + for owner in ["preferences", "command", "tool", "card", "search"] { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.draft", json!({})).unwrap(); + match owner { + "preferences" => { + h.call("ui.prefs", json!({"open":"Interface"})); + } + "command" => { + h.app.start("solid.fillet"); + assert!(h.app.dialog.is_some()); + } + "tool" => { + h.app.start("inspect.continuity"); + assert!(h.app.tool.is_some()); + } + "search" => { + h.app.start("ui.palette"); + } + _ => { + h.frames(3); + let effect = h.app.session.status_items().remove(0); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:key", item.id)}})).unwrap(); + assert!(h.app.viewport_status.open_key.is_some()); + } + } + h.frames(3); + h.ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + let before = h.app.session.status_items(); + let mut passes = 0; + // One tap, like ui.key, rather than a held Escape that makes the next press a repeat. + let mut escape = crate::test_support::key(egui::Key::Escape); + escape.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); + let _output = h.ctx.run_ui( + egui::RawInput { + screen_rect: Some(h.ctx.content_rect()), + time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0), + focused: true, + events: escape, + ..Default::default() + }, + |ui| { + passes += 1; + h.app.logic(ui.ctx()); + h.app.ui(ui); + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("check Escape ownership over layout passes"); + } + }, + ); + assert!(passes >= 2, "the frame must actually repeat its layout"); + assert_eq!(h.app.session.status_items(), before, "{owner} owns one Escape over every pass"); + assert!(!h.app.prefs_window.open && h.app.dialog.is_none() && h.app.tool.is_none() && !h.app.ui.palette_open); + assert!(h.app.viewport_status.open_key.is_none()); + h.frame(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.status_items().is_empty(), "{owner} releases the following idle Escape"); + } +} + +#[test] +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(); + if window_first { + h.call("ui.prefs", json!({"open":"Interface"})); + } + h.app.run("inspect.draft", json!({})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().remove(0); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + if !window_first { + h.step(&json!({"click":{"handle":format!("viewport_status:{}:key", item.id)}})).unwrap(); + h.call("ui.prefs", json!({"open":"Interface"})); + } + h.frames(3); + let window = crate::scenario::rect_of("prefs:window").unwrap(); + let mut targets = vec![format!("viewport_status:{}:close", item.id)]; + if !window_first { + targets.push("viewport_status:card".into()); + } + for target in targets { + let at = crate::scenario::rect_of(&target).unwrap().center(); + assert!(window.contains(at), "the real Preferences window must overlap {target}"); + let layer = h.ctx.layer_id_at(at).unwrap(); + assert_eq!(layer.order, egui::Order::Middle, "Preferences stays above {target}"); + assert_ne!(layer.id, egui::Id::new("viewport_status_row")); + assert_ne!(layer.id, egui::Id::new("viewport_status_key")); + } + let at = crate::scenario::handle_at(&format!("viewport_status:{}:close", item.id)).unwrap(); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + assert!(h.app.prefs_window.open); + assert!(h.app.session.analysis.is_some(), "an occluded HUD close never receives the window's click"); + } +} + +#[test] +fn analysis_status_focused_dialog_and_real_text_popup_preserve_owner_priority_in_both_policies() { + for enabled in [false, true] { + for popup in [false, true] { + let mut h = Harness::new(); + h.app.preferences.viewport_section_status = false; + h.call("ui.prefs", json!({"viewport_analysis_status":enabled})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.draft", json!({})).unwrap(); + h.app.start("solid.fillet"); + h.frames(3); + let at = crate::scenario::handle_at("dialog:Radius").unwrap(); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + assert!(h.ctx.memory(|memory| memory.focused().is_some()), "the real radius editor has focus"); + if popup { + h.call("ui.click", json!({"x":at.x,"y":at.y,"button":"right"})); + assert!(egui::Popup::is_any_open(&h.ctx), "a real editor context menu is open"); + let mut reached_guard = false; + let mut tap = crate::test_support::key(egui::Key::Escape); + tap.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: false, + repeat: false, + modifiers: Default::default(), + }); + let _output = h.ctx.run_ui( + egui::RawInput { + screen_rect: Some(h.ctx.content_rect()), + time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0), + focused: true, + events: tap, + ..Default::default() + }, + |ui| { + let first = ui.ctx().current_pass_index() == 0; + if first { + assert!(egui::Popup::is_any_open(ui.ctx()), "the surviving popup predicate reaches the global guard"); + reached_guard = true; + } + h.app.logic(ui.ctx()); + if first { + assert!(h.app.dialog.is_some()); + assert!(!h.app.esc_handled, "routing first refusal is not completed cancellation"); + assert!(ui.ctx().input(|input| input.key_pressed(egui::Key::Escape)), "the real popup still receives its Escape"); + } + h.app.ui(ui); + }, + ); + assert!(reached_guard); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(h.app.dialog.is_some(), "the first Escape closes only the editor's menu"); + assert!(h.app.session.analysis.is_some()); + } + // Plain TextEdit may blur at begin-pass: this asserts behavior, not guard capture. + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none(), "the dialog's own Escape still cancels in policy {enabled}"); + assert!(h.app.session.analysis.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.analysis.is_none(), enabled, "the idle Escape follows the selected policy"); + } + } +} + +fn analysis_escape_relayout_frame(h: &mut Harness, mut first_pass: impl FnMut(&crate::SolveApp)) { + let ctx = h.ctx.clone(); + let mut tap = crate::test_support::key(egui::Key::Escape); + tap.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); + let mut passes = 0; + let _output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(ctx.content_rect()), + time: Some(ctx.input(|input| input.time) + 1.0 / 60.0), + focused: true, + events: tap, + ..Default::default() + }, + |ui| { + passes += 1; + h.app.logic(ui.ctx()); + h.app.ui(ui); + if ui.ctx().current_pass_index() == 0 { + first_pass(&h.app); + ui.ctx().request_discard("check restored policy during the same Escape frame"); + } + }, + ); + assert!(passes >= 2, "the policy transition must actually cross a layout re-pass"); +} + +#[test] +fn analysis_status_preferences_cancel_can_restore_either_policy_without_reusing_escape() { + for saved in [false, true] { + let mut h = Harness::new(); + h.app.preferences.viewport_section_status = false; + h.call("ui.prefs", json!({"viewport_analysis_status":saved})); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"viewport_analysis_status":!saved})); + analysis_escape_relayout_frame(&mut h, |app| { + assert!(!app.prefs_window.open); + assert_eq!(app.preferences.viewport_analysis_status, saved, "Cancel restores policy in pass one"); + assert!(app.session.analysis.is_some()); + }); + assert_eq!(h.app.preferences.viewport_analysis_status, saved); + assert!(h.app.session.analysis.is_some(), "Cancel owns this Escape through pass two while restoring policy {saved}"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.analysis.is_none(), saved); + } +} + +#[test] +fn analysis_status_preferences_restore_false_keeps_underlying_dialog_through_the_owned_frame() { + let mut h = Harness::new(); + h.app.preferences.viewport_section_status = false; + h.call("ui.prefs", json!({"viewport_analysis_status":false})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.draft", json!({})).unwrap(); + h.app.start("solid.fillet"); + assert!(h.app.dialog.is_some()); + h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"viewport_analysis_status":true})); + analysis_escape_relayout_frame(&mut h, |app| { + assert!(!app.prefs_window.open); + assert!(!app.preferences.viewport_analysis_status, "Cancel restores false before pass two"); + assert!(app.dialog.is_some(), "Preferences shields the existing command dialog in pass one"); + }); + assert!(h.app.dialog.is_some(), "the same Escape must not cancel the underlying dialog in pass two"); + assert!(h.app.session.analysis.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none(), "the next physical Escape belongs to the command dialog"); + assert!(h.app.session.analysis.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.analysis.is_some(), "the restored legacy policy keeps shading on idle Escape"); +} + +/// The countdown ring around a toast's × (#7971) is the share of its time left: whole when it +/// starts, half at half time, gone when it is over, and whole for times no clock gives. +#[test] +fn the_countdown_ring_follows_the_time_left() { + assert_eq!(countdown_left(10.0, 10.0, 6.0), 1.0); + assert!((countdown_left(10.0, 13.0, 6.0) - 0.5).abs() < 1e-6); + assert!((countdown_left(0.0, 1.5, 6.0) - 0.75).abs() < 1e-6); + assert_eq!(countdown_left(10.0, 16.0, 6.0), 0.0); + assert_eq!(countdown_left(10.0, 99.0, 6.0), 0.0); + assert_eq!(countdown_left(10.0, 5.0, 6.0), 1.0, "a start in the future"); + for (since, now, lifetime) in [(f64::NAN, 1.0, 6.0), (0.0, f64::INFINITY, f64::INFINITY), (0.0, 1.0, 0.0), (0.0, 0.0, 0.0)] { + let left = countdown_left(since, now, lifetime); + assert!((0.0..=1.0).contains(&left), "{since} {now} {lifetime}: {left}"); + } + // The arc: from twelve o'clock, clockwise (y down), over that share of a turn, on the ring. + let centre = pos2(100.0, 50.0); + for left in [1.0_f32, 0.75, 0.5, 0.25, 0.01] { + let arc = countdown_arc(centre, 10.0, left); + assert!(arc.len() >= 2, "{left}"); + assert!(arc.iter().all(|p| ((*p - centre).length() - 10.0).abs() < 1e-3), "{left}: on the ring"); + assert!((arc[0] - pos2(100.0, 40.0)).length() < 1e-3, "starts at twelve o'clock"); + assert!((swept(&arc, centre) - left).abs() < 1e-3, "{left}: sweeps {}", swept(&arc, centre)); + } + assert!((countdown_arc(centre, 10.0, 0.25).last().copied().unwrap() - pos2(110.0, 50.0)).length() < 1e-3, "a quarter ends at three o'clock"); + for bad in [0.0_f32, -1.0, f32::NAN] { + assert!(countdown_arc(centre, 10.0, bad).is_empty(), "{bad}"); + } + assert!(countdown_arc(centre, f32::NAN, 0.5).is_empty() && countdown_arc(centre, 0.0, 0.5).is_empty()); +} + +/// The share of a turn an arc of `points` sweeps clockwise (y down) round `centre`. +fn swept(points: &[Pos2], centre: Pos2) -> f32 { + let turn = std::f32::consts::TAU; + points.windows(2).map(|w| ((w[1] - centre).angle() - (w[0] - centre).angle()).rem_euclid(turn)).sum::() / turn +} + +/// The status toast has a close (×) at its top right and no Close button (#7971): the × closes it +/// without opening the run's messages (a click elsewhere on it still does), its ring shows the +/// share of its six seconds left, and behind an issue report the × does nothing. +#[test] +fn the_status_toast_closes_with_its_x_and_shows_its_time_left() { + let t = Tokens::get(); + let mut h = Harness::new(); + h.app.home.open = false; + // Half its time gone. + h.app.status = Some(("Opened".into(), crate::now_ms() - STATUS_TOAST_MS / 2.0, false)); + h.frames(2); + let toast = crate::scenario::rect_of("toast").expect("the status toast"); + let close = crate::scenario::handle_at("toast:status:close").expect("its ×"); + assert!(toast.contains(close) && close.x > toast.center().x && close.y <= toast.center().y + 1.0, "top right: {close:?} in {toast:?}"); + fn ring(shape: &Shape, ink: Color32) -> Option> { + match shape { + Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink) && p.points.len() >= 2 => Some(p.points.clone()), + Shape::Vec(shapes) => shapes.iter().find_map(|s| ring(s, ink)), + _ => None, + } + } + let arc = h.shapes.iter().find_map(|s| ring(&s.shape, t.text_dim)).expect("the ring is drawn"); + assert!((swept(&arc, close) - 0.5).abs() < 0.05, "about half a turn left: {}", swept(&arc, close)); + assert!(h.widget("Close").is_some(), "the × has an accessible name"); + // Behind an issue report it does nothing. + crate::issue_report::set_cad_input_blocked_for_test(true); + h.step(&json!({"click": {"handle": "toast:status:close"}})).unwrap(); + h.frames(2); + crate::issue_report::set_cad_input_blocked_for_test(false); + assert!(h.app.status.is_some(), "not closed behind a report"); + h.step(&json!({"click": {"handle": "toast:status:close"}})).unwrap(); + h.frames(2); + assert!(h.app.status.is_none(), "closed"); + assert!(!h.app.messages.open, "the × doesn't open the messages"); + assert!(crate::scenario::rect_of("toast").is_none()); + // A click elsewhere on it still opens them. + h.app.status = Some(("Opened".into(), crate::now_ms(), false)); + h.frames(2); + h.step(&json!({"click": {"handle": "status:chip"}})).unwrap(); + h.frames(2); + assert!(h.app.messages.open, "the card opens the messages"); +} + +#[test] +fn section_only_status_modal_owners_take_first_escape_across_real_layout_repasses() { + for owner in ["preferences", "command", "tool", "search"] { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"viewport_analysis_status":false,"viewport_section_status":true})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + match owner { + "preferences" => { + h.call("ui.prefs", json!({"open":"Interface"})); + } + "command" => h.app.start("solid.fillet"), + "tool" => h.app.start("inspect.continuity"), + _ => h.app.start("ui.palette"), + } + h.frames(3); + h.ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + let cut = h.app.session.section; + analysis_escape_relayout_frame(&mut h, |app| { + assert_eq!(app.session.section, cut, "{owner} owns the tap in pass one"); + assert!(!app.prefs_window.open && app.dialog.is_none() && app.tool.is_none() && !app.ui.palette_open); + }); + assert_eq!(h.app.session.section, cut, "{owner} keeps the cut through the discarded frame"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.section.is_none(), "a distinct idle tap clears Section after {owner}"); + } +} + +#[test] +fn section_only_status_real_editor_popup_then_dialog_own_escape_before_the_cut() { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"viewport_analysis_status":false,"viewport_section_status":true})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + h.app.start("solid.fillet"); + h.frames(3); + let at = crate::scenario::handle_at("dialog:Radius").unwrap(); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + assert!(h.ctx.memory(|memory| memory.focused().is_some())); + h.call("ui.click", json!({"x":at.x,"y":at.y,"button":"right"})); + assert!(egui::Popup::is_any_open(&h.ctx)); + let cut = h.app.session.section; + analysis_escape_relayout_frame(&mut h, |app| { + assert!(app.dialog.is_some(), "the real popup has first refusal before Dialog cancellation"); + assert_eq!(app.session.section, cut); + }); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(h.app.dialog.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.section, cut, "the distinct second tap belongs to the command dialog"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.section.is_none()); +} + +#[test] +fn section_and_analysis_key_release_distinct_idle_escape_to_the_newest_cut() { + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().into_iter().find(|effect| effect.has_key).unwrap(); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap(); + assert!(h.app.viewport_status.open_key.is_some()); + let cut = h.app.session.section; + analysis_escape_relayout_frame(&mut h, |app| { + assert!(app.viewport_status.open_key.is_none()); + assert_eq!(app.session.section, cut, "a coexisting analysis key owns the first tap"); + }); + assert!(h.app.session.analysis.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.section.is_none() && h.app.session.analysis.is_some()); +} + +#[test] +fn section_only_preferences_cancel_restores_either_policy_and_shields_existing_dialog() { + for saved in [false, true] { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"viewport_analysis_status":false,"viewport_section_status":saved})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + if !saved { + h.app.start("solid.fillet"); + } + h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"viewport_section_status":!saved})); + let cut = h.app.session.section; + analysis_escape_relayout_frame(&mut h, |app| { + assert!(!app.prefs_window.open); + assert_eq!(app.preferences.viewport_section_status, saved); + assert!(!app.preferences.viewport_analysis_status); + assert_eq!(app.dialog.is_some(), !saved, "enabled routing protects the existing form in pass one"); + assert_eq!(app.session.section, cut); + }); + assert_eq!(h.app.dialog.is_some(), !saved, "no tap is reinserted during the actual second pass"); + if !saved { + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.section, cut); + } + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.section.is_none(), saved, "both-false restores the actual legacy idle policy"); + } +} + +#[test] +fn section_only_status_stays_below_preferences_for_paint_and_hit_testing() { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"viewport_analysis_status":false,"viewport_section_status":true})); + h.app.run("inspect.section", json!({"plane":"XY","at":4})).unwrap(); + h.frames(3); + h.call("ui.prefs", json!({"open":"Interface"})); + h.frames(3); + let effect = h.app.session.status_items().into_iter().find(|e| e.command == "inspect.section").unwrap(); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + let at = crate::scenario::handle_at(&format!("viewport_status:{}:close", item.id)).unwrap(); + assert!(crate::scenario::rect_of("prefs:window").unwrap().contains(at)); + assert_eq!(h.ctx.layer_id_at(at).unwrap().order, egui::Order::Middle); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + assert!(h.app.prefs_window.open && h.app.session.section.is_some()); +} + +// Summary fixtures below exercise component kinds unavailable from current production producers. +#[test] +fn summary_component_uses_supplied_newest_and_aggregate_attention_at_three_and_four() { + use crate::ui_kit::{StatusItem, StatusKind, StatusPresentation, viewport_status_summary}; + for count in [3, 4, 12] { + for newest_kind in [StatusKind::Mode, StatusKind::Result, StatusKind::Attention] { + for warning in [false, true] { + let ctx = crate::fonts::test_context(); + let mut state = StatusPresentation::default(); + let items: Vec<_> = (0..count) + .map(|i| StatusItem { + id: format!("summary_{i}"), + kind: if i == 0 { + newest_kind + } else if warning && i == 1 { + StatusKind::Attention + } else { + StatusKind::Mode + }, + name: format!("Source {i}"), + setting: format!("{i} mm"), + has_key: false, + editable: false, + }) + .collect(); + for width in [320.0, 1600.0] { + let free = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 600.0)); + // Measure settled native Area content, after its sizing/fade frames. + // Production animation and every exact paint assertion remain unchanged. + let settle_frames = (ctx.global_style().animation_time * 60.0).ceil() as usize + 2; + for _ in 0..settle_frames { + let mut warm = ctx.run_ui(egui::RawInput { screen_rect: Some(free), ..Default::default() }, |_| { + viewport_status_summary(&ctx, free, &items, "summary_0", &mut state, |_, _| {}); + }); + warm.textures_delta.clear(); + } + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(free), ..Default::default() }, |_| { + viewport_status_summary(&ctx, free, &items, "summary_0", &mut state, |_, _| {}); + }); + let mut name = false; + let mut more = false; + let mut dot = false; + let t = Tokens::get(); + for shape in &output.shapes { + crate::test_support::each_shape(&shape.shape, &mut |shape| match shape { + Shape::Text(text) if text.galley.job.text == "Source 0" => name = true, + Shape::Text(text) if text.galley.job.text == format!("+{}", count - 1) => more = true, + Shape::Circle(circle) if circle.fill == t.warning && circle.radius == t.space_3 => dot = true, + _ => {} + }); + } + output.textures_delta.clear(); + assert!(name && more, "supplied newest is the first slot at every width"); + assert_eq!(dot, warning || newest_kind == StatusKind::Attention); + } + } + } + } +} + +fn summary_three_effects() -> Harness { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.call("ui.prefs", json!({"single_surface_analysis":false})); + h.app.run("inspect.zebra", json!({})).unwrap(); + h.app.run("inspect.continuity", json!({})).unwrap(); + h.app.run("inspect.section", json!({"plane":"XY","at":3})).unwrap(); + assert_eq!(h.app.session.status_items().len(), 3); + h.frames(4); + h +} + +#[test] +fn summary_count_switch_is_independent_of_width_and_legacy_preference_keeps_effects() { + let mut h = summary_three_effects(); + for summary in [true, false] { + h.call("ui.prefs", json!({"viewport_status_summary":summary})); + for width in [640, 1600] { + h.call("ui.resize", json!({"width":width,"height":900})); + h.frames(4); + assert_eq!(crate::scenario::rect_of("viewport_status:summary").is_some(), summary); + for effect in h.app.session.status_items() { + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + assert_eq!(crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).is_some(), !summary); + } + } + } + h.app.run("inspect.continuity", json!({"clear":true})).unwrap(); + h.call("ui.prefs", json!({"viewport_status_summary":true})); + for width in [640, 1600] { + h.call("ui.resize", json!({"width":width,"height":900})); + h.frames(4); + assert!(crate::scenario::rect_of("viewport_status:summary").is_none()); + assert_eq!(h.app.session.status_items().len(), 2); + } +} + +#[test] +fn summary_native_menu_escape_is_close_only_then_newest_replacement_clears_and_unfolds() { + let mut h = summary_three_effects(); + let oldest = h.app.session.status_items().first().unwrap().id; + h.app.run("inspect.draft", json!({})).unwrap(); + h.frames(3); + assert_eq!(h.app.session.status_items().first().unwrap().id, oldest); + let latest = h.app.session.newest_status_item(&[solidcraft_engine::status_items::StatusId::SurfaceAnalysis]).unwrap(); + let item = crate::viewport_status::presentation(&h.app, &latest).unwrap(); + assert!(crate::scenario::rect_of(&format!("viewport_status:{}:summary", item.id)).is_some()); + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + assert!(crate::scenario::rect_of("menu:viewport_status_clear_all").is_some()); + let mut previous = f32::NEG_INFINITY; + for effect in h.app.session.status_items() { + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + let close = crate::scenario::rect_of(&format!("viewport_status:{}:close", item.id)).unwrap(); + assert!(close.top() > previous, "menu order follows oldest slots rather than newest activation"); + previous = close.top(); + } + analysis_escape_relayout_frame(&mut h, |app| { + assert_eq!(app.session.status_items().len(), 3, "footer Esc hint is not a clear-all binding"); + }); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert_eq!(h.app.session.status_items().len(), 3); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(3); + assert!(h.app.session.analysis.is_none()); + assert!(h.app.session.section.is_some() && !h.app.session.continuity.is_empty()); + assert!(crate::scenario::rect_of("viewport_status:summary").is_none()); +} + +#[test] +fn summary_popup_closes_on_preference_count_and_replacement_transitions() { + for transition in ["disable", "count", "replace", "zero"] { + let mut h = summary_three_effects(); + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + match transition { + "disable" => h.app.preferences.viewport_status_summary = false, + "count" => { + h.app.run("inspect.section", json!({"clear":true})).unwrap(); + } + "replace" => { + h.app.run("inspect.draft", json!({})).unwrap(); + } + _ => crate::viewport_status::dispatch(&mut h.app, vec![crate::ui_kit::StatusAction::ClearAll]), + } + crate::viewport_status::reconcile(&mut h.app, &h.ctx); + assert!(!egui::Popup::is_any_open(&h.ctx), "{transition} closes only the stale summary owner"); + assert!(!h.app.viewport_status.owns_focus(&h.ctx)); + h.frames(3); + if transition == "zero" { + assert!(h.app.session.status_items().is_empty()); + } else { + assert!(h.app.session.analysis.is_some()); + } + } +} + +#[test] +fn summary_dispatch_reconciles_current_sources_hidden_items_and_actual_clear_failures() { + use crate::ui_kit::StatusAction; + let mut h = summary_three_effects(); + let old = h.app.session.status_items().into_iter().find(|e| e.command == "inspect.zebra").unwrap(); + let old = crate::viewport_status::presentation(&h.app, &old).unwrap().id; + h.app.run("inspect.draft", json!({})).unwrap(); + crate::viewport_status::dispatch(&mut h.app, vec![StatusAction::Clear(old)]); + assert!(h.app.session.analysis.is_some(), "stale identity cannot clear a replacement"); + // The host picker blocks actual command execution before engine dispatch. + h.app.services.pick_open = Some(Box::new(|_| Box::pin(std::future::pending()))); + h.app.pick_open(|_, _| {}); + assert!(h.app.file_picker_busy()); + crate::viewport_status::dispatch(&mut h.app, vec![StatusAction::ClearAll]); + assert_eq!(h.app.session.status_items().len(), 3, "failed commands never remove UI-owned copies"); + assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); + h.app.file_picker = None; + h.app.preferences.viewport_section_status = false; + crate::viewport_status::dispatch(&mut h.app, vec![StatusAction::ClearAll]); + assert!(h.app.session.analysis.is_none() && h.app.session.continuity.is_empty()); + assert!(h.app.session.section.is_some(), "footer scope is currently represented effects only"); +} + +#[test] +fn summary_footer_pointer_and_native_keyboard_dispatch_clear_all_without_a_key_binding() { + for keyboard in [false, true] { + let mut h = summary_three_effects(); + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(3); + if keyboard { + assert!(h.app.viewport_status.owns_focus(&h.ctx), "opening the badge keeps keyboard traversal at the status controls"); + // Follow real native focus traversal rather than assigning a widget ID. + let footer = crate::scenario::rect_of("menu:viewport_status_clear_all").unwrap(); + let mut reached = false; + for _ in 0..16 { + h.step(&json!({"key":"Tab"})).unwrap(); + reached = + h.ctx.memory(|memory| memory.focused()).and_then(|id| h.ctx.read_response(id)).is_some_and(|response| response.rect == footer); + if reached { + break; + } + } + assert!(reached, "native Tab reaches the footer"); + h.step(&json!({"key":"Tab", "shift":true})).unwrap(); + assert!(h.ctx.memory(|memory| memory.focused()).and_then(|id| h.ctx.read_response(id)).is_some_and(|response| response.rect != footer)); + h.step(&json!({"key":"Tab"})).unwrap(); + h.step(&json!({"key":"Space"})).unwrap(); + } else { + h.step(&json!({"click":{"handle":"menu:viewport_status_clear_all"}})).unwrap(); + } + h.frames(3); + assert!(h.app.session.status_items().is_empty()); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(crate::scenario::rect_of("viewport_status:summary").is_none()); + } +} + +#[test] +fn summary_preferences_cancel_restores_layout_independently_of_all_effect_policies() { + for saved in [false, true] { + let mut h = summary_three_effects(); + h.call("ui.prefs", json!({"viewport_status_summary":saved})); + h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"viewport_status_summary":!saved})); + analysis_escape_relayout_frame(&mut h, |app| { + assert_eq!(app.preferences.viewport_status_summary, saved); + assert_eq!(app.session.status_items().len(), 3); + assert!(!app.preferences.single_surface_analysis); + assert!(app.preferences.viewport_analysis_status && app.preferences.viewport_section_status); + assert!(app.preferences.persistent_ribbon_pressed); + }); + h.frames(3); + assert_eq!(crate::scenario::rect_of("viewport_status:summary").is_some(), saved); + } +} diff --git a/crates/ui-egui/src/viewport_status.rs b/crates/ui-egui/src/viewport_status.rs new file mode 100644 index 0000000000..e0dcd957b8 --- /dev/null +++ b/crates/ui-egui/src/viewport_status.rs @@ -0,0 +1,206 @@ +//! Thin presentation adapter: persistent effects and activation order belong to the session. +use crate::{ + SolveApp, + ui_kit::{self, StatusAction, StatusItem, StatusKind}, +}; +use solidcraft_engine::status_items::{StatusId, StatusItem as Effect, StatusSetting}; + +fn allowed(app: &SolveApp) -> Vec { + let mut ids = Vec::new(); + if app.preferences.viewport_analysis_status { + ids.extend([StatusId::SurfaceAnalysis, StatusId::Continuity]); + } + if app.preferences.viewport_section_status { + ids.push(StatusId::Section); + } + ids +} + +fn eligible(app: &SolveApp) -> Vec { + app.session.status_items().iter().filter(|effect| presentation(app, effect).is_some()).map(|effect| effect.id).collect() +} + +pub(crate) fn enabled(app: &SolveApp) -> bool { + app.preferences.viewport_analysis_status || app.preferences.viewport_section_status +} + +/// Canonical plane coordinates: reversing the clipping side never reverses the offset. +/// Axis planes use world coordinates; other planes use signed distance to the origin. +pub(crate) fn section_coordinates(origin: [f64; 3], normal: [f64; 3]) -> Option<(Option<&'static str>, f64, [f64; 3])> { + let scale = normal.iter().fold(0.0_f64, |a, b| a.max(b.abs())); + if !normal.iter().chain(origin.iter()).all(|v| v.is_finite()) || !scale.is_finite() || scale == 0.0 { + return None; + } + let scaled = normal.map(|n| n / scale); + let length = scaled.iter().map(|n| n * n).sum::().sqrt(); + let mut n = scaled.map(|n| n / length); + if n.iter().find(|v| v.abs() > 1e-8).is_some_and(|v| *v < 0.0) { + n = n.map(|v| -v); + } + for (i, axis) in ["X", "Y", "Z"].into_iter().enumerate() { + if n.iter().enumerate().all(|(j, v)| (*v - if i == j { 1.0 } else { 0.0 }).abs() < 1e-8) { + return Some((Some(axis), *origin.get(i)?, n)); + } + } + let distance = origin.iter().zip(n).map(|(o, n)| o * n).sum::(); + distance.is_finite().then_some((None, distance, n)) +} + +pub(crate) fn section_setting(app: &SolveApp, origin: [f64; 3], normal: [f64; 3], hatch: bool) -> Option { + let (axis, offset, _) = section_coordinates(origin, normal)?; + let distance = crate::prefs::show_mm(app, offset, 1); + let mut text = match axis { + Some(axis) => format!("{axis} {distance}"), + None => crate::i18n::format("ui.viewport_status_section_tilted", &[("offset", &distance)]), + }; + if hatch { + text = crate::i18n::format("ui.viewport_status_section_hatch", &[("setting", &text)]); + } + Some(text) +} + +fn identity(effect: &Effect) -> String { + format!("{:?}:{}", effect.id, effect.command) +} + +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(); + } + if value.iter().zip(vector).all(|(a, b)| (*a + b).abs() < 1e-8) { + return format!("−{axis}"); + } + } + let [x, y, z] = value; + format!("({x:.2}, {y:.2}, {z:.2})") +} + +pub(crate) fn presentation(app: &SolveApp, effect: &Effect) -> Option { + if !allowed(app).contains(&effect.id) { + return None; + } + let command = solidcraft_engine::find_command(&effect.command)?; + let setting = match effect.setting { + StatusSetting::Zebra { stripes } => crate::i18n::format("ui.viewport_status_stripes", &[("count", &stripes.to_string())]), + StatusSetting::Draft { pull, angle_deg } => format!("{} · {angle_deg}°", direction(pull)), + StatusSetting::Environment => String::new(), + StatusSetting::Accessibility { direction: from } => crate::i18n::format("ui.viewport_status_from", &[("direction", &direction(from))]), + StatusSetting::Curvature { radius_mm } => format!("R {}", crate::prefs::show_mm(app, radius_mm, 1)), + StatusSetting::Continuity { edge_count } => crate::i18n::format("ui.viewport_status_edges", &[("count", &edge_count.to_string())]), + StatusSetting::Section { origin_mm, normal, hatch } => section_setting(app, origin_mm, normal, hatch)?, + }; + Some(StatusItem { + id: identity(effect), + kind: StatusKind::Mode, + name: crate::toolbar::persistent_button_name(app, command), + setting, + has_key: effect.has_key, + editable: effect.id == StatusId::Section, + }) +} + +fn clear(app: &mut SolveApp, effect: &Effect) { + if let Err(error) = app.run(&effect.clear.command, serde_json::json!({"clear": effect.clear.params.clear})) { + app.set_status(error, true); + } +} + +pub(crate) fn has_effect(app: &SolveApp) -> bool { + app.session.newest_status_item(&eligible(app)).is_some() +} + +/// Called only after existing Escape owners have yielded. A repeat cannot cascade clears. +pub(crate) fn escape(app: &mut SolveApp, repeat: bool) -> 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) + { + return true; + } + if let Some(effect) = app.session.newest_status_item(&eligible(app)) { + clear(app, &effect); + return true; + } + false +} + +/// Reconcile before shortcut routing so a vanished native popup cannot own Escape invisibly. +pub(crate) fn reconcile(app: &mut SolveApp, ctx: &egui::Context) { + let items: Vec<_> = app.session.status_items().iter().filter_map(|effect| presentation(app, effect)).collect(); + 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); +} + +pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect) { + reconcile(app, ctx); + if !enabled(app) { + app.viewport_status.open_key = None; + return; + } + let effects = app.session.status_items(); + if !app.preferences.viewport_analysis_status { + app.viewport_status.open_key = None; + } + let items: Vec<_> = effects.iter().filter_map(|effect| presentation(app, effect)).collect(); + if items.is_empty() { + app.viewport_status = Default::default(); + return; + } + let content = crate::shell::content_rect(app, viewport); + if !content.is_finite() || !content.is_positive() { + return; + } + let mut free = crate::shell::workspace_content(app, content); + for card in &app.shell.cards { + free.min.y = free.top().max(card.bottom()).min(free.bottom()); + } + if let Some(layout) = &app.viewport.layout { + free.max.x = free.right().min(layout.axis_view.left()).max(free.left()); + } + 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 newest = app.session.newest_status_item(&eligible(app)).map(|effect| identity(&effect)); + let actions = if app.preferences.viewport_status_summary && items.len() >= 3 { + ui_kit::viewport_status_summary(ctx, free, &items, newest.as_deref().unwrap_or_default(), &mut app.viewport_status, key_contents) + } else { + ui_kit::viewport_status_row(ctx, free, &items, &mut app.viewport_status, key_contents) + }; + dispatch(app, actions); +} + +/// Resolve requests against current represented effects, never a menu-open snapshot. +pub(crate) fn dispatch(app: &mut SolveApp, actions: Vec) { + for action in actions { + let effects: Vec<_> = app.session.status_items().into_iter().filter(|effect| presentation(app, effect).is_some()).collect(); + match action { + StatusAction::ClearAll => { + for effect in effects { + clear(app, &effect); + } + app.viewport_status.open_key = None; + } + StatusAction::Clear(id) => { + if let Some(effect) = effects.iter().find(|effect| identity(effect) == id) { + clear(app, effect); + app.viewport_status.open_key = None; + } + } + StatusAction::Edit(id) => { + 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 dcccc1c8e5..92132dc4a2 100644 --- a/crates/ui-egui/src/viewport_status_fixture.rs +++ b/crates/ui-egui/src/viewport_status_fixture.rs @@ -34,15 +34,20 @@ pub(crate) fn control(app: &mut crate::SolveApp, params: &Value) -> Result vec![attention()], "row" => vec![zebra(), section()], "kinds" => 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 or key".into()), + _ => return Err("preset must be off, mode, result, attention, row, kinds, many or key".into()), }; - app.viewport_status_fixture = Fixture { items, presentation: StatusPresentation::default() }; + app.viewport_status_fixture.items = items; Ok(json!({"preset": preset, "count": app.viewport_status_fixture.items.len()})) } pub(crate) fn show(app: &mut crate::SolveApp, ctx: &egui::Context, viewport: egui::Rect) { - if app.viewport_status_fixture.items.is_empty() { + let fixture = &mut app.viewport_status_fixture; + let folded = app.preferences.viewport_status_summary && fixture.items.len() >= 3; + fixture.presentation.reconcile_summary(ctx, if folded { fixture.items.iter().map(|item| item.id.clone()).collect() } else { Vec::new() }); + if fixture.items.is_empty() { + fixture.presentation.open_key = None; return; } let content = crate::shell::content_rect(app, viewport); @@ -60,7 +65,7 @@ pub(crate) fn show(app: &mut crate::SolveApp, ctx: &egui::Context, viewport: egu return; } let fixture = &mut app.viewport_status_fixture; - let actions = crate::ui_kit::viewport_status_row(ctx, free, &fixture.items, &mut fixture.presentation, |ui, _| { + 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)); @@ -78,10 +83,20 @@ pub(crate) fn show(app: &mut crate::SolveApp, ctx: &egui::Context, viewport: egu ui.label(label); }); } - }); + }; + // Deliberately use the oldest specimen as newest to exercise chronology/slot divergence. + // This is explicit specimen metadata, never an engine activation claim. + let newest = fixture.items.first().map(|item| item.id.as_str()).unwrap_or_default(); + let actions = if folded { + crate::ui_kit::viewport_status_summary(ctx, free, &fixture.items, newest, &mut fixture.presentation, key_contents) + } else { + crate::ui_kit::viewport_status_row(ctx, free, &fixture.items, &mut fixture.presentation, key_contents) + }; for action in actions { - if let StatusAction::Clear(id) = action { - fixture.items.retain(|item| item.id != id); + match action { + StatusAction::Clear(id) => fixture.items.retain(|item| item.id != id), + StatusAction::ClearAll => fixture.items.clear(), + StatusAction::Edit(_) => {} } } } diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 139fca2d3d..51663b7735 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -81,6 +81,13 @@ pub fn grouped_commands<'a>(tab: &str, panel: &str, commands: &[&'a CommandSpec] out } +/// The ids of a panel's commands that are in none of its groups: [`grouped_commands`] puts them +/// last, without a heading. +pub fn ungrouped(tab: &str, panel: &str, commands: &[&CommandSpec]) -> Vec<&'static str> { + let groups = groups(tab, panel); + commands.iter().map(|c| c.id).filter(|id| !groups.iter().any(|g| g.ids.contains(id))).collect() +} + pub struct Section { pub tab: &'static str, pub panel: &'static str, @@ -246,8 +253,15 @@ pub const SECTIONS: &[Section] = &[ Section { tab: "SKETCH", panel: "CREATE", - promoted: 4, - ids: &["sketch.line", "sketch.rectangle.two_point", "sketch.circle.center", "sketch.arc.three_point", "sketch.infinite"], + promoted: 5, + ids: &[ + "sketch.line", + "sketch.rectangle.two_point", + "sketch.circle.center", + "sketch.arc.three_point", + "sketch.construction_line", + "sketch.infinite", + ], }, Section { tab: "SKETCH", @@ -291,6 +305,7 @@ pub const SECTIONS: &[Section] = &[ ], }, // Sketch dimensions are driven by parameters, so they are one click away while sketching. + Section { tab: "SKETCH", panel: "INSPECT", promoted: 1, ids: &["sketch.inspect"] }, Section { tab: "SKETCH", panel: "CONFIGURE", promoted: 2, ids: CONFIGURE }, Section { tab: "SKETCH", panel: "FINISH SKETCH", promoted: 2, ids: &["sketch.finish", "sketch.cancel"] }, // New Sketch leads the tab, as on MODEL: a sheet part starts from a sketch (#3250). @@ -431,9 +446,11 @@ pub const RIBBON_LABELS: &[(&str, &str)] = &[ ("sketch.circle.center", "Circle"), ("sketch.constraint.coincident", "Coincident"), ("sketch.constraint.horizontal_vertical", "Horiz./Vert."), + ("sketch.construction_line", "X-Lines"), ("sketch.create", "New Sketch"), ("sketch.dimension", "Dimension"), ("sketch.finish", "Finish Sketch"), + ("sketch.inspect", "Sketch Report"), ("sketch.pattern.circular", "Circular"), ("sketch.pattern.rectangular", "Rectangular"), ("sketch.rectangle.two_point", "Rectangle"), @@ -451,6 +468,11 @@ pub fn ribbon_button_label(command: &CommandSpec) -> &str { RIBBON_LABELS.iter().find(|(id, _)| *id == command.id).map_or(command.label, |(_, label)| *label) } +/// Families keep their full menu title while using the first command's compact ribbon name. +pub fn ribbon_family_label(family: &crate::sketch_families::Family) -> &'static str { + family.commands.first().and_then(|first| RIBBON_LABELS.iter().find(|(id, _)| id == first)).map_or(family.label, |(_, label)| *label) +} + /// Registry placement → default ribbon destination, after explicit id overrides. pub const PLACEMENTS: &[(&str, &str, &str, &str)] = &[ ("SOLID", "CREATE", "MODEL", "CREATE"), @@ -846,6 +868,9 @@ mod tests { } assert_eq!(ribbon_button_label(solidcraft_engine::find_command("sketch.create").unwrap()), "New Sketch"); assert_eq!(solidcraft_engine::find_command("sketch.create").unwrap().label, "Create Sketch"); + let construction = crate::sketch_families::family("sketch.construction_line").unwrap(); + assert_eq!(ribbon_family_label(construction), "X-Lines"); + assert_eq!(construction.label, "Construction Line"); } #[test] @@ -856,7 +881,12 @@ mod tests { assert!(lists.iter().any(|(_, (cmds, _))| cmds.iter().any(|x| x.id == c.id)), "lost toolbar command {} ({}/{})", c.id, c.tab, c.panel); } for (s, (cmds, n)) in lists { - assert!(n <= 4 || (s.tab == "SKETCH" && s.panel == "CONSTRAIN" && n == 7), "{}/{} promotes {n} commands", s.tab, s.panel); + assert!( + n <= 4 || (s.tab == "SKETCH" && ((s.panel == "CONSTRAIN" && n == 7) || (s.panel == "CREATE" && n == 5))), + "{}/{} promotes {n} commands", + s.tab, + s.panel + ); for id in s.ids { assert!(specs.iter().any(|c| c.id == *id), "unknown layout id {id}"); } diff --git a/crates/ui-egui/tests/icon_audit.rs b/crates/ui-egui/tests/icon_audit.rs index 52baff0206..760c81a579 100644 --- a/crates/ui-egui/tests/icon_audit.rs +++ b/crates/ui-egui/tests/icon_audit.rs @@ -180,6 +180,12 @@ fn requested_menu_icons_have_artwork_and_match_manifest() { h.call("ui.status_fixture", json!({"preset":preset})); h.frames(3); collect(&mut h, "viewport status specimens", &mut requested); + if preset == "kinds" { + click(&mut h, "viewport_status:summary"); + h.frames(3); + collect(&mut h, "viewport status summary menu", &mut requested); + escape(&mut h); + } } h.call("ui.status_fixture", json!({"preset":"off"})); collect(&mut h, "initial browser and timeline", &mut requested); diff --git a/crates/ui-egui/tests/scenarios/browser_bodies_folders.json b/crates/ui-egui/tests/scenarios/browser_bodies_folders.json index df3c8c128d..b45898baa2 100644 --- a/crates/ui-egui/tests/scenarios/browser_bodies_folders.json +++ b/crates/ui-egui/tests/scenarios/browser_bodies_folders.json @@ -1,5 +1,6 @@ [ {"note": "Bodies folders (#4217, Preferences › Interface): a nested assembly of plain boxes. Frame:1 holds the body Plate and the placement Pin:1, which holds the body Shaft. The design itself has no bodies, so every Bodies row is a component's."}, + {"note": "Each row's indent handle is where its depth puts it, so their order shows each row's depth."}, {"call": "engine.execute", "params": {"command": "component.create", "params": {"name": "Frame"}}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 20, "height": 5, "body_name": "Plate"}}}, {"call": "engine.execute", "params": {"command": "component.create", "params": {"name": "Pin"}}}, @@ -8,14 +9,14 @@ {"expect": {"bodies": 2, "errors": 0, "prefs": {"browser_bodies_folders": "several"}}}, {"note": "Several bodies only (the default): each lone body sits straight under its placement, one level in, with no Bodies row."}, - {"expect": {"browser_has": ["Frame:1", "Plate", "Pin:1", "Shaft"], "browser_lacks": ["Bodies"], "handle_order": ["eye:Frame:1", "eye:Plate"]}}, - {"expect": {"handle_order": ["eye:Pin:1", "eye:Shaft"]}}, + {"expect": {"browser_has": ["Frame:1", "Plate", "Pin:1", "Shaft"], "browser_lacks": ["Bodies"], "handle_order": ["indent:Frame:1", "indent:Plate"]}}, + {"expect": {"handle_order": ["indent:Pin:1", "indent:Shaft"]}}, {"note": "A second body in Frame brings Frame's folder back; Pin's lone body stays folded."}, {"call": "engine.execute", "params": {"command": "component.activate", "params": {"component": "Frame"}}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 5, "width": 20, "height": 15, "corner": [60, 0, 0], "body_name": "Rib"}}}, {"call": "engine.execute", "params": {"command": "component.activate", "params": {"component": "root"}}}, - {"expect": {"bodies": 3, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["eye:Bodies", "eye:Plate"]}}, + {"expect": {"bodies": 3, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["indent:Bodies", "indent:Plate"]}}, {"note": "Never: every component's bodies sit straight under it, two of them too."}, {"call": "ui.prefs", "params": {"browser_bodies_folders": "never"}}, @@ -23,7 +24,7 @@ {"note": "Always: every component keeps its folder, Pin's too (the last Bodies row drawn, between Pin:1 and Shaft)."}, {"call": "ui.prefs", "params": {"browser_bodies_folders": "always"}}, - {"expect": {"prefs": {"browser_bodies_folders": "always"}, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["eye:Pin:1", "eye:Bodies", "eye:Shaft"]}}, + {"expect": {"prefs": {"browser_bodies_folders": "always"}, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["indent:Pin:1", "indent:Bodies", "indent:Shaft"]}}, {"note": "A value this version doesn't know is refused and changes nothing."}, {"call": "ui.prefs", "params": {"browser_bodies_folders": "sometimes"}, "fail": true}, diff --git a/crates/ui-egui/tests/scenarios/colour_vision_setting.json b/crates/ui-egui/tests/scenarios/colour_vision_setting.json index 96c2429c08..5d3b47ca66 100644 --- a/crates/ui-egui/tests/scenarios/colour_vision_setting.json +++ b/crates/ui-egui/tests/scenarios/colour_vision_setting.json @@ -5,7 +5,7 @@ {"widget": "Interface"}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, - {"expect": {"shown": "Colour vision", "prefs": {"dialog": "Interface"}, "same_width": "prefs:window"}}, + {"expect": {"shown": "Color vision", "prefs": {"dialog": "Interface"}, "same_width": "prefs:window"}}, {"widget": "Deutan"}, {"expect": {"prefs": {"color_vision": "deutan"}, "same_width": "prefs:window"}}, {"widget": "Cancel"}, diff --git a/crates/ui-egui/tests/scenarios/constraint_glyphs_hide_when_zoomed_out.json b/crates/ui-egui/tests/scenarios/constraint_glyphs_hide_when_zoomed_out.json new file mode 100644 index 0000000000..8d88baec8b --- /dev/null +++ b/crates/ui-egui/tests/scenarios/constraint_glyphs_hide_when_zoomed_out.json @@ -0,0 +1,16 @@ +[ + {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}}, + {"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 0], [0, 40]]}}}, + {"call": "engine.execute", "params": {"command": "sketch.constraint.horizontal_vertical", "params": {"line": "l1", "mode": "vertical"}}}, + {"call": "engine.execute", "params": {"command": "sketch.constraint.horizontal_vertical", "params": {"line": "l1", "mode": "vertical"}}}, + {"call": "ui.view", "params": {"view": "top"}}, + {"call": "ui.view", "params": {"view": "fit"}}, + {"expect": {"glyphs": {"k1": 1, "k2": 1}}}, + {"call": "ui.camera", "params": {"distance": 20000}}, + {"expect": {"glyphs": {"k1": 0, "k2": 0}}}, + {"call": "ui.prefs", "params": {"hide_constraint_glyphs_zoomed_out": false}}, + {"expect": {"glyphs": {"k1": 1, "k2": 1}}}, + {"call": "ui.prefs", "params": {"hide_constraint_glyphs_zoomed_out": true}}, + {"call": "ui.view", "params": {"view": "fit"}}, + {"expect": {"glyphs": {"k1": 1, "k2": 1}}} +] diff --git a/crates/ui-egui/tests/scenarios/dialog_buttons_top.json b/crates/ui-egui/tests/scenarios/dialog_buttons_top.json index 9ef1f2faf9..68a01d46fc 100644 --- a/crates/ui-egui/tests/scenarios/dialog_buttons_top.json +++ b/crates/ui-egui/tests/scenarios/dialog_buttons_top.json @@ -4,7 +4,7 @@ {"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"}}, - {"click": {"handle": "prefs:dialog_buttons:top"}}, + {"widget": "Top", "near": "Dialog buttons"}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "dialog_buttons": "top"}}}, {"note": "Extrude picks the sketch's one profile; its buttons are in the title row."}, diff --git a/crates/ui-egui/tests/scenarios/print_face_ctrl_b.json b/crates/ui-egui/tests/scenarios/print_face_ctrl_b.json new file mode 100644 index 0000000000..abb6cecfc0 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/print_face_ctrl_b.json @@ -0,0 +1,16 @@ +[ + {"note":"#8429: Ctrl+B marks the one selected flat face as its body's print face, the design saves it, and Ctrl+B on it again clears it. Without a face selected it says what to select."}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"length":20,"width":10,"height":5,"body_name":"Block"}}}, + {"call":"ui.camera","params":{"target":[10,5,2.5],"yaw":-0.785398,"pitch":0.61548,"distance":70}}, + {"key":"B","ctrl":true}, + {"expect":{"status":"Select one flat face","errors":0}}, + {"click":{"world":[20,5,2.5]}}, + {"expect":{"selection":1}}, + {"key":"B","ctrl":true}, + {"expect":{"status":"Block: print face set","errors":0}}, + {"shot":"print_face_ctrl_b"}, + {"call":"engine.execute","params":{"command":"file.save_as","params":{"path":"$DIR/print-face.json"}}}, + {"expect":{"file_contains":{"file":"print-face.json","text":"\"print_faces\""}}}, + {"key":"B","ctrl":true}, + {"expect":{"status":"Block: print face cleared","errors":0}} +] diff --git a/crates/ui-egui/tests/scenarios/s27_move_triad.json b/crates/ui-egui/tests/scenarios/s27_move_triad.json index 195a6cf05d..c837164943 100644 --- a/crates/ui-egui/tests/scenarios/s27_move_triad.json +++ b/crates/ui-egui/tests/scenarios/s27_move_triad.json @@ -13,7 +13,7 @@ {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [20, 0, 0], "max": [40, 30, 10]}}}, {"start": "solid.move"}, {"click": {"world": [30, 15, 10]}}, - {"drag": [{"gizmo": "rz", "turn": 60}, {"gizmo": "rz", "turn": 150}], "steps": 12}, + {"drag": [{"gizmo": "rz", "turn": 200}, {"gizmo": "rz", "turn": 290}], "steps": 12}, {"key": "Enter"}, {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [15, 5, 0], "max": [45, 25, 10]}}}, {"shot": "s27_move_triad"} diff --git a/crates/ui-egui/tests/scenarios/s44_about_licences.json b/crates/ui-egui/tests/scenarios/s44_about_licences.json index cee6376b5f..72d338fb3c 100644 --- a/crates/ui-egui/tests/scenarios/s44_about_licences.json +++ b/crates/ui-egui/tests/scenarios/s44_about_licences.json @@ -1,9 +1,9 @@ [ - {"note": "Help ▸ About ▸ Licences shows SolidCraft's licences and every third-party crate's, built into the program."}, + {"note": "Help ▸ About ▸ Licenses shows SolidCraft's licenses and every third-party crate's, built into the program."}, {"call": "ui.help", "params": {"item": "about"}}, - {"widget": "Licences"}, - {"expect": {"shown": "Third-party licences"}}, - {"widget": "Third-party licences"}, - {"expect": {"shown": "Copy all licences"}}, + {"widget": "Licenses"}, + {"expect": {"shown": "Third-party licenses"}}, + {"widget": "Third-party licenses"}, + {"expect": {"shown": "Copy all licenses"}}, {"expect": {"shown": "Third-party software in SolidCraft"}} ] diff --git a/crates/ui-egui/tests/scenarios/sketch_centerline_toolbar_icon.json b/crates/ui-egui/tests/scenarios/sketch_centerline_toolbar_icon.json index cb8307de0a..6b334a0e77 100644 --- a/crates/ui-egui/tests/scenarios/sketch_centerline_toolbar_icon.json +++ b/crates/ui-egui/tests/scenarios/sketch_centerline_toolbar_icon.json @@ -10,7 +10,8 @@ {"key": "Escape"}, {"click": {"handle": "panel:SKETCH:CONSTRAIN"}}, {"expect": {"shown_handles": ["toolbar:sketch.construction"]}}, - {"click": [1000, 500]}, + {"note": "Close the menu with Escape: a fixed canvas pixel can be inside the menu after ribbon families or pins change its position."}, + {"key": "Escape"}, {"start": "sketch.rectangle.two_point"}, {"click": {"sketch": [0, 0]}}, {"click": {"sketch": [30, 20]}}, diff --git a/crates/ui-egui/tests/scenarios/sketch_mirror_profile_click.json b/crates/ui-egui/tests/scenarios/sketch_mirror_profile_click.json new file mode 100644 index 0000000000..29a35be0a6 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_mirror_profile_click.json @@ -0,0 +1,19 @@ +[ + {"note": "#8390: one click inside a closed profile selects its whole boundary for the shared Entities input; a double-click selects a curve's chain."}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}}, + {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}}, + {"queued": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[-10, -40], [-10, 40]]}}}, + {"expect": {"errors": 0, "sketch_curves": {"own": 5}}}, + + {"note": "Open Mirror; a single click inside the rectangle selects the whole profile."}, + {"start": "sketch.mirror"}, + {"expect": {"dialog": "SketchModify(Mirror"}}, + {"click": {"sketch": [20, 15]}}, + {"expect": {"dialog": "SketchModify(Mirror", "errors": 0}}, + + {"note": "Pick the line as the mirror line and OK: all four rectangle sides reflect (4 -> 8, plus the line)."}, + {"click": {"handle": "dialog:Mirror line"}}, + {"click": {"sketch": [-10, 0]}}, + {"click": {"handle": "dialog:OK"}}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 9}}} +] diff --git a/crates/ui-egui/tests/scenarios/sketch_mirror_shared_line.json b/crates/ui-egui/tests/scenarios/sketch_mirror_shared_line.json new file mode 100644 index 0000000000..ceac028dfa --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_mirror_shared_line.json @@ -0,0 +1,17 @@ +[ + {"note": "A mirror line that is itself among the entities (a chain or profile picked in one action) is not rejected: the command leaves the line in place and mirrors the rest."}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}}, + {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}}, + {"expect": {"errors": 0, "sketch_curves": {"own": 4}}}, + + {"note": "One click inside the rectangle selects the whole profile (including its left edge)."}, + {"start": "sketch.mirror"}, + {"click": {"sketch": [20, 15]}}, + {"expect": {"dialog": "SketchModify(Mirror"}}, + + {"note": "Pick the rectangle's own left edge as the mirror line and OK: the left edge stays put and the other three sides reflect (4 -> 7)."}, + {"click": {"handle": "dialog:Mirror line"}}, + {"click": {"sketch": [0, 15]}}, + {"click": {"handle": "dialog:OK"}}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 7}}} +] diff --git a/crates/ui-egui/tests/scenarios/viewport_status_item.json b/crates/ui-egui/tests/scenarios/viewport_status_item.json index 38a4422fb7..0c61856121 100644 --- a/crates/ui-egui/tests/scenarios/viewport_status_item.json +++ b/crates/ui-egui/tests/scenarios/viewport_status_item.json @@ -224,5 +224,157 @@ {"shot": "viewport-status-focus-dark-tritan"}, {"click": {"handle": "viewport_status:draft:close"}}, {"frames": 2}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "light","color_vision": "off","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-light-off"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-light-off"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "light","color_vision": "protan","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-light-protan"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-light-protan"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "light","color_vision": "deutan","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-light-deutan"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-light-deutan"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "light","color_vision": "tritan","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-light-tritan"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-light-tritan"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "dark","color_vision": "off","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-dark-off"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-dark-off"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "dark","color_vision": "protan","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-dark-protan"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-dark-protan"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "dark","color_vision": "deutan","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-dark-deutan"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-dark-deutan"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, + {"note": "Summary specimens only; mixed kinds and count4 are not persistent engine evidence."}, + {"call": "ui.prefs","params": {"theme": "dark","color_vision": "tritan","viewport_status_summary": true}}, + {"call": "ui.status_fixture","params": {"preset": "kinds"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"shot": "summary-specimen-rest-dark-tritan"}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 4}, + {"shot": "summary-specimen-menu-dark-tritan"}, + {"key": "Escape"}, + {"frames": 3}, + {"expect": {"count": {"name": "viewport_status:count","value": 3}}}, + {"call": "ui.status_fixture","params": {"preset": "many"}}, + {"frames": 4}, + {"expect": {"count": {"name": "viewport_status:count","value": 4}}}, + {"click": {"handle": "viewport_status:summary"}}, + {"frames": 3}, + {"click": {"handle": "menu:viewport_status_clear_all"}}, + {"frames": 3}, {"call": "ui.status_fixture","params": {"preset": "off"}} ] diff --git a/crates/ui-egui/tests/stand_up_offer.rs b/crates/ui-egui/tests/stand_up_offer.rs index 72f8ed48e7..e58af67d94 100644 --- a/crates/ui-egui/tests/stand_up_offer.rs +++ b/crates/ui-egui/tests/stand_up_offer.rs @@ -2,7 +2,8 @@ //! as `stand_up:stand`, `stand_up:leave` and `stand_up:never`, it stays up through a camera move //! and a command until answered, and `ui.prefs {"stand_up": false}` leaves the file as it is. //! A file from a program not known to save Y up gets a small note instead by default (#7717), -//! with `stand_up:stand`, `stand_up:never` and `stand_up:dismiss`; Preferences picks the dialog or nothing. +//! with `stand_up:stand`, `stand_up:never` and its close (×), `toast:stand_up:close` (also published +//! as `stand_up:dismiss`, the Close button's handle, #7971); Preferences picks the dialog or nothing. use serde_json::json; use solidcraft_ui_egui::prefs::StandUpUnknown; use solidcraft_ui_egui::scenario::Harness; @@ -256,9 +257,14 @@ fn an_unknown_exporter_lying_on_its_back_gets_the_note_not_the_dialog() { assert!(h.app.stand_up_note, "as the note"); assert!(painted(&h, "Did this part open the wrong way up?")); assert!(h.widget("Stand this part up?").is_none(), "no dialog"); - for name in ["stand_up:stand", "stand_up:never", "stand_up:dismiss"] { + for name in ["stand_up:stand", "stand_up:never", "toast:stand_up:close", "stand_up:dismiss"] { assert!(h.at(&json!({"handle": name})).is_ok(), "{name} is published"); } + // No Close button (#7971): the only control named Close is the note's ×, which the old handle finds. + let close = h.at(&json!({"handle": "toast:stand_up:close"})).unwrap(); + let near = |p: [f64; 2]| (p[0] - close[0]).abs() < 1.0 && (p[1] - close[1]).abs() < 1.0; + assert!(h.widget("Close").is_some_and(near), "the × is the Close: {:?} at {close:?}", h.widget("Close")); + assert!(near(h.at(&json!({"handle": "stand_up:dismiss"})).unwrap())); assert!(h.at(&json!({"handle": "stand_up:leave"})).is_err(), "the dialog's buttons are not"); // It doesn't hold anything up: the view and commands work while it shows. h.run(&json!([{"camera": {"yaw": 0.6, "pitch": 0.4}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1, "corner": [100, 0, 0]}}}])) @@ -328,7 +334,7 @@ fn the_notes_dont_ask_again_turns_the_offer_off() { #[test] fn the_note_closes_on_dismiss_and_by_itself() { let mut h = opened_box(ON_ITS_BACK, UNKNOWN, |_| {}); - h.run(&json!([{"click": {"handle": "stand_up:dismiss"}}])).unwrap(); + h.run(&json!([{"click": {"handle": "toast:stand_up:close"}}])).unwrap(); h.frames(3); assert!(h.app.stand_up.is_none(), "dismissed"); assert!(h.app.preferences.offer_stand_up, "the preference stays"); diff --git a/crates/ui-egui/tests/window_widths.rs b/crates/ui-egui/tests/window_widths.rs index 1e3d1c1fda..828d42c0d5 100644 --- a/crates/ui-egui/tests/window_widths.rs +++ b/crates/ui-egui/tests/window_widths.rs @@ -104,6 +104,7 @@ fn command_dialogs_shrink_to_their_content_within_limits() { h.app.dialog = None; h.app.params = None; h.app.design_properties = None; + h.app.sketch_report = None; h.app.bom = None; // Properties (Inspect → Measure) opens the Properties card. h.app.menu.props = None; diff --git a/docs/3d-mouse.md b/docs/3d-mouse.md index 9b7eecb10c..c7dfea236e 100644 --- a/docs/3d-mouse.md +++ b/docs/3d-mouse.md @@ -17,9 +17,12 @@ versions. Two preferences opt into holding the model: but picks the point each frame because device apps do not report rest. While editing a sketch, the cap pans and zooms without changing the sketch's initial view. After you -adjust the view with the navigation cube, the cap can rotate it too. The Navigation +adjust the view with the navigation cube, the cap can rotate it too. Turn on **Turn sketches with +the 3D mouse** (`space_mouse_sketch_orbit: true`) to let the cap rotate a sketch seen square on as +well, as the mouse can. The Navigation page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an -invert switch and a speed for the 3D mouse. The web app reads +invert switch and a speed for the 3D mouse. The cap's zoom direction comes from its own invert +switch; **Reverse zoom direction** under Wheel and trackpad reverses the wheel only (#8993). The web app reads it too, through the browser; Firefox needs a 3DxWare setting first (see [Getting started › 3D mouse in the browser](book/src/guide/getting-started.md#3d-mouse-in-the-browser)). @@ -83,7 +86,7 @@ know, is ignored: | Message | Meaning | |---|---| | `{"t":"hello","server":"MAKMOUSE","device":"MAKMOUSE"}` | The program's name (shown in Preferences) and, optionally, a name in the device's USB manufacturer or product string. A 3D mouse with that name is then left to the program, so its motion doesn't reach the view twice. | -| `{"t":"axis","x":4.0,"y":-2.5}` | Motion since the last message, in points: it orbits as a drag of that many points would (the model follows), or pans or zooms in the current mode. Where the view can't turn (a sketch being edited, a flat page) an orbit pans. | +| `{"t":"axis","x":4.0,"y":-2.5}` | Motion since the last message, in points: it orbits as a drag of that many points would (the model follows), or pans or zooms in the current mode. Where the view can't turn (a sketch being edited and seen square on, unless **Turn sketches with the 3D mouse** is on; a flat page) an orbit pans. | | `{"t":"zoom","d":1.0}` | Zoom steps (8 % of the distance each); positive zooms in, as a wheel turned forward. Reverse zoom direction applies. Stops at the model only when **Stop zoom at the model** is on. | | `{"t":"mode","mode":"pan","on":true}` | What `axis` drives: `orbit` (the default), `pan` or `zoom` (up zooms in, 8 points a step). `"on":false` goes back to orbit. | | `{"t":"view","name":"iso"}` | A standard view: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, or `fit` to frame everything. | diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 7b313b39f7..7341489c25 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -63,16 +63,25 @@ clicks and scrolling over the panel stay in the chat. The docked layout retains icon, the agent's state, and a count of anything waiting for your answer. Click the icon to unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does. - **The agent switcher** under the title picks the agent for this conversation; switching stops - the one running. Next to it, when the agent has modes of its own (Claude Code has *Default*, - *Accept Edits*, *Plan*…), a second drop-down sets the agent's mode. When the agent reports its models (an ACP agent's - model list, or an endpoint's `/models`), a model drop-down shows the one in use and changes it; - SolidCraft remembers the last choice for each agent and puts it back on the next session. An - endpoint's agent also keeps it as its default model. An agent that reports no models shows no - drop-down. When the models come from more than one provider (Hermes and OpenCode name each - model `Provider · model`, or say `Provider: …` in its description), a provider drop-down comes - first and the model drop-down lists only that provider's models, without the provider in front. - It opens on the provider of the model in use; picking another provider changes nothing until one - of its models is chosen. Models with no provider named are listed under *Other*. + the one running. When an ACP agent reports **configuration options** (including OpenCode V2), + a compact inline strip below it shows the agent's model, reasoning effort, mode, and any other + supported select options, wrapping when space is tight. Hover a selector for its full label and + description. Their names and choices come from the agent: not every model offers adjustable + reasoning effort. Changing the model can change or remove the effort choices. A selection is + shown as applied only after the agent reports it; a rejected change leaves the confirmed + selection in place and shows an error. +- **Older agents and API endpoints** keep their model and mode drop-downs when they report those + choices (an ACP agent's legacy model/mode lists, or an endpoint's `/models`). SolidCraft remembers + the last model choice for each agent and puts it back on the next session when still available. + An endpoint's agent also keeps it as its default model. An agent that reports no choices shows + no drop-down. When these model lists include more than one provider (Hermes and OpenCode name + each model `Provider · model`, or say `Provider: …` in its description), a provider drop-down + comes first and the model drop-down lists only that provider's models, without the provider in + front. It opens on the provider of the model in use; picking another provider changes nothing + until one of its models is chosen. Models with no provider named are listed under *Other*. +- **Reasoning effort is not permissions.** The agent's thinking setting does not change + SolidCraft's Ask / Edits / All approval policy. Reasoning options belong to the current session; + changing them here does not rewrite OpenCode's configuration or another OpenCode session. Messages use right-aligned user bubbles and plain assistant text. Consecutive tool calls share an activity group: click a row to inspect its output; failed calls have an error tint. Approval @@ -263,6 +272,10 @@ short notice when the reply was empty, or when an explicit edit request received Ordinary answers to questions do not show it. Edit requests are recognised conservatively from English opening words such as “make”, “rename”, or “can you move”; other phrasing may stay quiet. Cancelled and failed turns keep their existing messages. Text describing a call is never run. +A reply that stopped at its length limit, or that the model declined, says that instead of the +tool-calling notice: an endpoint reports it as `max_tokens` or `refusal` (Anthropic), or as +`length`, `content_filter` or a refusal (OpenAI-compatible). A tool call cut off at the length +limit is not run. ### An agent inside WSL (Windows) diff --git a/docs/book/scripts/equation-circle.json b/docs/book/scripts/equation-circle.json new file mode 100644 index 0000000000..40e5e06b05 --- /dev/null +++ b/docs/book/scripts/equation-circle.json @@ -0,0 +1,6 @@ +{"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Circle"}}, + {"command": "sketch.equation_curve", "params": {"x": "20 * cos(t)", "y": "20 * sin(t)", "start": 0, "end": "2 * pi"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "view.apply", "params": {"view": "top"}} +]} diff --git a/docs/book/scripts/equation-flower.json b/docs/book/scripts/equation-flower.json new file mode 100644 index 0000000000..3a2cda5acf --- /dev/null +++ b/docs/book/scripts/equation-flower.json @@ -0,0 +1,6 @@ +{"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Flower"}}, + {"command": "sketch.equation_curve", "params": {"x": "(20 + 4 * cos(6 * t)) * cos(t)", "y": "(20 + 4 * cos(6 * t)) * sin(t)", "start": 0, "end": "2 * pi"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "Flower", "distance": 6, "body_name": "Flower"}} +]} diff --git a/docs/book/scripts/equation-involute.json b/docs/book/scripts/equation-involute.json new file mode 100644 index 0000000000..4e867fdb71 --- /dev/null +++ b/docs/book/scripts/equation-involute.json @@ -0,0 +1,8 @@ +{"commands": [ + {"command": "parameters.add", "params": {"name": "r", "expression": "20 mm"}}, + {"command": "sketch.create", "params": {"plane": "XY", "name": "Involute"}}, + {"command": "sketch.equation_curve", "params": {"x": "r * (cos(t) + t * sin(t))", "y": "r * (sin(t) - t * cos(t))", "start": 0, "end": 1.5}}, + {"command": "sketch.circle.center", "params": {"center": [0, 0], "diameter": 40}}, + {"command": "sketch.finish", "params": {}}, + {"command": "view.apply", "params": {"view": "top"}} +]} diff --git a/docs/book/scripts/equation-line.json b/docs/book/scripts/equation-line.json new file mode 100644 index 0000000000..ff1aefe92f --- /dev/null +++ b/docs/book/scripts/equation-line.json @@ -0,0 +1,6 @@ +{"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Line"}}, + {"command": "sketch.equation_curve", "params": {"explicit": true, "y": "x / 2", "start": 0, "end": 40}}, + {"command": "sketch.finish", "params": {}}, + {"command": "view.apply", "params": {"view": "top"}} +]} diff --git a/docs/book/scripts/equation-parabola.json b/docs/book/scripts/equation-parabola.json new file mode 100644 index 0000000000..fd14cf23b8 --- /dev/null +++ b/docs/book/scripts/equation-parabola.json @@ -0,0 +1,6 @@ +{"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Parabola"}}, + {"command": "sketch.equation_curve", "params": {"explicit": true, "y": "x * x / 20", "start": -30, "end": 30}}, + {"command": "sketch.finish", "params": {}}, + {"command": "view.apply", "params": {"view": "top"}} +]} diff --git a/docs/book/scripts/equation-spiral.json b/docs/book/scripts/equation-spiral.json new file mode 100644 index 0000000000..80ca0e9587 --- /dev/null +++ b/docs/book/scripts/equation-spiral.json @@ -0,0 +1,6 @@ +{"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Spiral"}}, + {"command": "sketch.equation_curve", "params": {"x": "2 * t * cos(t)", "y": "2 * t * sin(t)", "start": 0, "end": "4 * pi"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "view.apply", "params": {"view": "top"}} +]} diff --git a/docs/book/scripts/equation-wave.json b/docs/book/scripts/equation-wave.json new file mode 100644 index 0000000000..8e862639b3 --- /dev/null +++ b/docs/book/scripts/equation-wave.json @@ -0,0 +1,6 @@ +{"commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Wave"}}, + {"command": "sketch.equation_curve", "params": {"explicit": true, "y": "8 * sin(x / 5)", "start": 0, "end": "20 * pi"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "view.apply", "params": {"view": "top"}} +]} diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 6c309d6357..de0960efcd 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -7,6 +7,7 @@ - [Getting started](guide/getting-started.md) - [A tour of the window](guide/ui-tour.md) - [Sketching and constraints](guide/sketching.md) + - [Equation curves](guide/equation-curves.md) - [Solid features](guide/features.md) - [Parameters and expressions](guide/parameters.md) - [Components and joints](guide/assemblies.md) diff --git a/docs/book/src/dev/schemas.md b/docs/book/src/dev/schemas.md index 3d3106f278..70dd3d7f7e 100644 --- a/docs/book/src/dev/schemas.md +++ b/docs/book/src/dev/schemas.md @@ -50,7 +50,8 @@ handler and everything it passes `p` to: `targets` overrides it), in that key's description; where it has no effect, in the summary or in the description of the key it might seem to change (bounding solid's `bodies`). A feature that makes no body (scale) declares `name` and `participants` itself instead, as `body_name` - and `body_names` would do nothing. It keeps `participants` because `timeline.redefine` passes + and `body_names` would do nothing; a construction axis, point or plane flattens + `ConstructionCommon` or `PlaneCommon`, which hold just those two. It keeps `participants` because `timeline.redefine` passes a feature's own back, though for a scale it changes nothing either; its own description says so. For the primitives (box, cylinder, sphere, torus, cone) `add_feature` also reads `targets`; a new primitive must join the `primitive` match in `add_feature_in`, and its contract declares @@ -259,6 +260,12 @@ calling that reader. | `ScaleFactors` | scale's `factors`: three `NumberExpr`s (strict words a bad item differently; see its doc) | `features::scale` | | `AxisDir` | X, Y or Z, or a non-zero `[x, y, z]` / `{x, y, z}` (cylinder's, cone's and coil's `axis`) | `features::axis_dir_form` | | `FeatureCommon` | `name`, `body_name`, `body_names`, `participants`, flattened | `features::add_feature` | +| `ConstructionCommon`, `PlaneCommon` | `name` and `participants` of a construction axis or point, or of a construction plane (whose `name` is a `PlaneName`), flattened | `features::add_feature` | +| `PlaneName` | a construction plane's name, which may not be an origin plane's (strict names the command called; see its doc) | `features::check_plane_name` | +| `PlaneParam` | a plane: an origin or construction plane's name, a construction plane's id, or `{origin?, x_dir, y_dir}` / `{origin?, normal}` | `features::plane_param` | +| `PlaneOrFace` | a plane, or a planar face of a body `{face, body?, index?, path?, fixed?}` (offset and at-angle planes' `base`) | `features::plane_param`, `sketch::face::face_pick_form` | +| `GeoRefParam` | geometry a construction feature is built from: a picked face, edge or vertex, a sketch point or curve, a point, a plane, an axis, a construction point | `construct::geo_form` | +| `AxisLine` | an at-angle plane's `axis`: X, Y, Z, a construction axis, `{origin?, dir}` or a model edge or face | `features::axis_line_object`, `construct::geo_form` | ### When no shared type fits @@ -274,7 +281,10 @@ Add one to `types.rs`, next to the reader it mirrors: sentence, such as the handler's ``unknown operation `x` (…)``, in `whole(…)`, and the check passes it on as it is. Otherwise start it with "must": the check names the key, so "must be a point [x, y, z] of numbers" becomes ``"`corner` must be a point [x, y, z] of numbers"``. (Any - other message comes out as ``"`corner`: …"``.) + other message comes out as ``"`corner`: …"``.) Where the handler puts serde's own message + after the key (``"`point`: missing field `p`"``), wrap it in `keyed(…)`: the check puts the key + and a colon in front and leaves the rest as it is, instead of rewording "missing field" and + "invalid type" as it does for the type's own fields. - Its `JsonSchema` describes the same values, with a description. Where JSON Schema can't say something the reader checks (ignoring case, a byte length), say it in the description. A pattern that allows surrounding whitespace must allow exactly what Rust's `trim` removes: @@ -286,6 +296,11 @@ Add one to `types.rs`, next to the reader it mirrors: `face_and_edge_refs_match_the_pick_readers`). - A handler that reads a key more leniently than any type can (a wrong-typed value read as if the key were absent) gets the stricter type: that difference is what warn and strict report. +- A reader that tries forms in order, where some forms, once chosen, refuse the value rather + than letting a later form take it (`construct::geo`: `{sketch, point: [1, 2, 3]}` is a + sketch reference without a point, never a fixed point), needs a schema that keeps that order: + give each later form a `not` naming the keys of the earlier forms that refuse, as + `GeoRefParam` does, and test values that two forms would read differently. ## Checklist diff --git a/docs/book/src/guide/equation-curves.md b/docs/book/src/guide/equation-curves.md new file mode 100644 index 0000000000..ebcbf4d93c --- /dev/null +++ b/docs/book/src/guide/equation-curves.md @@ -0,0 +1,130 @@ +# Equation curves + +An equation curve is a curve you **describe with a rule** instead of drawing it with clicks. +You type the rule, and SolidCraft draws the curve that follows it, exactly, every time. + +It is the way to get shapes that are hard to draw by hand: a smooth wave, a parabola, a spiral, +or the curved side of a gear tooth. + +## Your first curve: a straight ramp + +1. Start a sketch: **SOLID › CREATE › Create Sketch**, then click a plane. +2. Click **SKETCH › CREATE › Equation Curve**. A window opens with a wave already filled in. +3. Set **Form** to **Explicit**. Now there is one rule to fill in, **y(x)**: *how high is the curve + at each x?* +4. Type `x / 2` in **y(x)**, `0` in **Start** and `40` in **End**. +5. Click **OK**. + +![The curve y = x / 2 from x = 0 to x = 40: a straight ramp](../generated/equation-line.png) + +The rule says: *the height is half of how far along you are*. At x = 10 the curve is 5 high, at +x = 40 it is 20 high. **Start** and **End** say where along x the curve begins and stops. + +## Explicit: one rule for the height + +With **Explicit**, you write the height `y` using `x`. Try these (same steps, other rules): + +| Rule in **y(x)** | Start | End | What you get | +|---|---|---|---| +| `x / 2` | `0` | `40` | a straight ramp (above) | +| `x * x / 20` | `-30` | `30` | a parabola, the shape of a thrown ball's path | +| `8 * sin(x / 5)` | `0` | `20 * pi` | a smooth wave | + +![The parabola y = x * x / 20 from x = -30 to x = 30](../generated/equation-parabola.png) + +![The wave y = 8 * sin(x / 5): two smooth waves along x](../generated/equation-wave.png) + +In the wave, `8` is how tall each bump is and `5` stretches it sideways: try `x / 10` and the +waves get twice as long. + +## Parametric: a rule for x and a rule for y + +Some curves go back on themselves, like a circle: for one x there are two heights. For those, +set **Form** to **Parametric** and give **two** rules, **x(t)** and **y(t)**. + +Think of `t` as **a clock**. It starts at **Start** and ticks up to **End**. At every moment the +two rules say where the pen is: across (`x`) and up (`y`). The curve is the path the pen draws +while the clock runs. + +| Rule in **x(t)** | Rule in **y(t)** | Start | End | What you get | +|---|---|---|---|---| +| `20 * cos(t)` | `20 * sin(t)` | `0` | `2 * pi` | a circle of radius 20 | +| `2 * t * cos(t)` | `2 * t * sin(t)` | `0` | `4 * pi` | a spiral, two turns | + +![A circle of radius 20 drawn from x = 20 cos(t), y = 20 sin(t)](../generated/equation-circle.png) + +For the circle, `cos` and `sin` walk the pen around, and `20` is how far from the middle it +stays. `2 * pi` is one full turn, so the circle closes. In the spiral, the distance from the +middle (`2 * t`) grows while the clock runs, so the pen moves outwards as it goes round. + +![A spiral of two turns drawn from x = 2 t cos(t), y = 2 t sin(t)](../generated/equation-spiral.png) + +## Angles are in radians + +`sin`, `cos` and `tan` count angles in **radians**, not degrees: a full turn is `2 * pi` +(about 6.28), half a turn is `pi`. You can also write an angle with its unit for **Start** and +**End**, such as `360 deg`. + +A number without a unit is in your design's unit (millimetres unless you changed it). + +## Other functions + +Besides `sin`, `cos` and `tan`, a rule can use `sqrt`, `exp`, `ln`, `pow`, `abs`, `min`, `max` +and the hyperbolic functions `sinh`, `cosh` and `tanh` (and their inverses `asinh`, `acosh`, +`atanh`), which take a plain number. A hanging chain is `20 * cosh(x / 20)` in **y(x)**, and the +flare of a horn is often written with `cosh` and `sinh`. + +## Using parameters: a gear tooth + +Rules can use the design's [parameters](parameters.md). Here a parameter `r` (20 mm) is the size +of a base circle, and the curve is the *involute* of that circle: the path the end of a string +makes as you unwind it from the circle. The sides of most gear teeth have this shape. + +| Rule in **x(t)** | Rule in **y(t)** | Start | End | +|---|---|---|---| +| `r * (cos(t) + t * sin(t))` | `r * (sin(t) - t * cos(t))` | `0` | `1.5` | + +![The involute of a 20 mm base circle, starting on the circle and curving away from it](../generated/equation-involute.png) + +Change `r` in the Parameters dialog and the curve redraws itself to match: the rule is kept, not +just the shape. + +## From curve to part + +An equation curve is an ordinary sketch curve, so you can build with it: + +- A curve that **ends where it starts** closes by itself, smoothly. Extrude it and you get a + solid. This flower uses a radius that goes in and out six times as the clock goes round once: + + | Rule in **x(t)** | Rule in **y(t)** | Start | End | + |---|---|---|---| + | `(20 + 4 * cos(6 * t)) * cos(t)` | `(20 + 4 * cos(6 * t)) * sin(t)` | `0` | `2 * pi` | + + ![The flower curve extruded 6 mm into a solid](../generated/equation-flower.png) + +- An **open** curve can be closed with lines: draw lines from its two ends, then extrude the + shape. For example, close a wave with three lines down to the x axis to get a wavy strip. +- It can be the **path** of a sweep. + +## Changing a curve + +Right-click the curve and choose **Edit Equation Curve**. The same window opens with the rules +in it; change them and click **OK**. + +## When something goes wrong + +- **The curve is tiny or huge.** Check the numbers in the rule and the Start/End range: `sin` + never goes past 1, so `sin(x)` alone is at most 1 mm high. Multiply it (`8 * sin(x)`). +- **The wave has too few bumps, or far too many.** Remember radians: `sin(x)` repeats every + `2 * pi` (6.28) along x, not every 360. +- **The circle has a gap.** End must be a whole turn: `2 * pi`, or `360 deg`. +- **A rule is rejected.** Use `*` for multiply (`2 * t`, not `2t`), and close every bracket. + +## For scripts and agents + +The commands are `sketch.equation_curve` and `sketch.equation_curve.edit` (see the +[command reference](../reference/commands.md)). The curve is a fit point spline through points +worked out from the rules, as many as keep it within the **Tolerance** (0.01 mm unless you set +another), up to 500. Its points are fixed, so other curves can start at its ends. When a parameter +a rule uses changes, the points follow. The pictures on this page come from the scripts in +`docs/book/scripts/equation-*.json`. diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 0ae750e970..a7815aa5eb 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -15,6 +15,12 @@ it. | Construct | offset plane (at a distance or through a point), plane at angle, plane along path | | Inspect | measure, section analysis, interference, compare bodies, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | +**Quick measurement** (off by default; turn it on under Preferences › Interface): with edges, +faces, sketch curves or points selected, the view's bottom right corner, above History, shows +what they measure: an edge's length (Ø and R for a round one), a face's area, a point's position, +two items' distance and angle, several edges' or faces' total. A click copies it. The Measure tool +(I) does more. + Extrude, Revolve, Sweep, Loft, Coil and the Box, Cylinder, Sphere and Torus choose their operation from what their material does, until you pick one: **Join** when it makes one body with a body it touches (a block against a wall, a ring round a hole, a boss meeting only part of a @@ -230,6 +236,13 @@ across). Large bodies are sampled more coarsely, at about `max_points` (eight mi each; `sampling.spacing_mm` says what was used, and a larger `max_points` (up to 50 million) samples them finer, at the cost of time. +When editing an Extrude in the UI, the Browser's bodies, sketches, construction objects and +feature rows follow the history marker before that feature. Later objects return when editing +ends. Rolling back manually keeps feature rows listed, so they remain available to edit. +A hidden body supplying the profile's face is shown temporarily during the edit, including +when a folder or component hides it. OK, Cancel and Escape end that display override; the body's +saved visibility and unrelated visibility changes are preserved. + ## Editing a feature from a script For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, `{origin, dir}`, or an edge or diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 7156b007f1..0e10f6be78 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -67,6 +67,17 @@ with a `slicer`, which sends without a dialog. They can start only the slicers i list and programs you added under **Custom…**; any other program path is refused, and an AI agent's request to save a mesh or send it to a slicer asks first and names the slicer. +A body can have a **print face**: the flat face it should lie on in the slicer. Select the face +and press **Ctrl+B** (or **Print Face** in the face's right-click menu); press it again to clear +it. The Send to Slicer window shows the print face of the one body being sent in its **Print +face** row, where you can pick another face in the viewport or clear it. Each send then turns a +copy of the body so the print face points down at Z = 0, with its centre of mass over the +origin; the design itself does not move. The face is tinted and outlined in the viewport, and a +copy made with Move/Copy or Mirror keeps it. A print face that is gone or no longer flat is +reported, and the body is sent as it lies. Curved faces and mesh bodies can't be print faces. +Known limitation: after a shell of a body that is already shelled, no face of that body can be +marked, as no sketch can be attached to one either; this needs kernel work, which is paused. + The list also has a plain **EasyPrint (Printables, in the browser)** row, and it is the one entry that works in the browser build too. EasyPrint is Printables' slicer, so choosing it uploads the selected bodies (a 3MF, 100 MB at most) to Printables' servers and opens the model there in your diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index 20f72e6702..d21239ad3b 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -60,13 +60,14 @@ or **Alt/Option** for 0.1. The mouse wheel over the focused field does the same, and the preview follows each step. An expression such as `width / 2` is left as typed. Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using -`+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod -trunc` and unit conversion. A number and the unit after it are one value: `10 mm / 2 mm` is 5 +`+ - * / ^ %`, parentheses, `sin cos tan asin acos atan atan2 sinh cosh tanh asinh acosh atanh sqrt +abs sign min max floor ceil round trunc mod exp ln log log10 pow hypot` (`log` is base 10) and unit +conversion. A number and the unit after it are one value: `10 mm / 2 mm` is 5 and `90 deg / 1 rad` is π/2, and a fraction of numbers before a unit is one value too (`1/2 in` is half an inch; after a `/` only written without spaces: `1 in / 1/2 in` is 2, while -`width / 10 / 2 mm` divides by 10 and then by 2 mm). A decimal comma works too, outside parentheses: `2,5 mm` is -`2.5 mm` (inside a function's parentheses the comma separates its arguments, so write -`max(2.5, 3)` there). A mixed number is one value, as imperial sizes are written: `2 1/8"` and +`width / 10 / 2 mm` divides by 10 and then by 2 mm). A decimal comma works too: `2,5 mm` is +`2.5 mm`, and `-(width-0,5)/2` is `-(width-0.5)/2` (directly in a function's parentheses the +comma separates its arguments, so write `max(2.5, 3)` there). A mixed number is one value, as imperial sizes are written: `2 1/8"` and `3 7/16 in` are 2.125 in and 3.4375 in (a whole number, a space, then a proper fraction of whole numbers). A length where an angle is expected (or the other way round) is an error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 4a6441f817..ebc546565b 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -17,6 +17,22 @@ and selected when the active body is a sheet), and builds parts from a sheet of Bends follow the rule's K-factor, so flat lengths match what a press brake will make. Tests check flat lengths against hand-computed bend allowances. +A rule is saved in its design, and a new design starts with only the default steel rule. To use +the same rules in every design, keep them as **personal rules**, saved in your profile: open +**Personal rules** at the top of Sheet Metal Rules. + +- **Save new** keeps the rule the manager shows under a name: its thickness, K factor, bend + radius, reliefs, gaps, corner relief shape and angle display. A bare number is saved with the + design's unit, so the rule makes the same sheet in a millimetre or an inch design. `Thickness` + and other expressions are kept as typed, and are worked out in the design the rule is used in. +- Picking a personal rule fills the manager with it: the design's own rule of that name, or else + a new rule. **OK** adds it to the design (or updates that rule); picking alone changes nothing. +- The save icon beside the box saves the manager's values over the picked personal rule. + **Rename** and **Delete** change the personal rule only, never the rules already in designs. + +A design keeps its own copy of each rule it uses, so it builds the same on a computer without +your personal rules. The profile keeps up to 64 of them. + A copy of a sheet metal body is sheet metal too. **Mirror** (`solid.mirror` with `bodies`) makes the opposite hand: its own flat pattern, DXF and bend table, the mirror image of the original's flat, with the same thickness side, bends and flat lengths, and holes where the diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 17f0176901..b01ea3543f 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -10,7 +10,7 @@ plane the sketch will lie on before you click. keeping what changed since the sketch was created or opened for editing: a new sketch is removed, 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. 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. +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 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 @@ -86,6 +86,37 @@ a line type of their own and keep showing. Projected geometry that carries the c line type, such as the trace of a plane, answers to **Projected Geometries** instead. The option changes display only. Construction curves were already outside every profile. +The **Construction Line** drop-down in Sketch **CREATE** draws infinite reference lines +directly. It contains two-point, horizontal, vertical, angle, parallel, perpendicular, tangent +through an external point, tangent at a curve point, common tangent, and angle-bisector tools. +These lines remain construction geometry regardless of the Sketch Palette's drawing line type. +Turn off **Extended construction line tools** in Preferences > Sketch to restore the earlier menu. + +For horizontal or vertical lines, click the point to pass through. For a line at an angle, +click its point, then type the angle from sketch X in the inline box and press Enter, or click +the direction. Angle expressions can refer to parameters and follow their later changes. +Parallel and perpendicular lines ask for a straight sketch line, then a point; +the reference stays picked so you can place several lines. Right-click clears the current +points and references; Escape ends the tool. + +Tangents through an external point take a circle or circular arc, the external point, and a +click choosing the desired preview. A common tangent takes two circles/arcs and a click choosing +the solution. Contacts outside an arc's drawn sweep are excluded. A tangent at a curve point +takes a circle/arc and a contact point, or an endpoint of an open spline or conic. Interior +free-form contacts and ellipse tangents are not supported by these tools yet. A bisector takes +two intersecting straight lines; click near the desired one of the two preview lines. Parallel +or coincident references have no unique angle bisector and are refused. + +Each placement is one undo step. Direction, parallel, perpendicular, tangency and bisector +relations are kept as ordinary sketch constraints; the reference geometry follows later edits +within those constraints. Programmatic callers can use `sketch.construction_line` with `mode`, +or one of its named variants. `near` chooses the closest solution; `solution` chooses its +zero-based index. For example: + +```json +{"command":"sketch.construction_line.parallel","params":{"curve":"l1","at":[20,10]}} +``` + Polygon tools show their edge outline while positioning the second point, so the orientation can be checked before placement. Inscribed and circumscribed polygons retain their support circle and update the outline when the inline **Sides** value changes. @@ -196,16 +227,10 @@ it smoothly. Right-click finishes an ordinary natural fit. Offset accepts both e handles and dense fitted curves; the second click sets the side and distance from the curve. **Equation Curve** draws a curve from formulas instead of clicks: x(t) and y(t) over a range of -t, or y = f(x) over a range of x. The formulas use the expression language and the design's -parameters, so `x = r * (cos(t) + t * sin(t))`, `y = r * (sin(t) - t * cos(t))` with t from 0 to -1 is the involute of a base circle of radius `r`. The variable is a plain number: `sin(t)` takes -it in radians, and a result without a unit is in the design's unit. The curve is a fit point -spline through points worked out from the formulas, as many as keep it within the tolerance -(0.01 mm unless you set another), up to 500; a curve that ends where it starts closes smoothly. -Its points are fixed, so other curves can start at its ends and close a profile for Extrude or -Revolve, and it can be a sweep path. When a parameter it uses changes, the points follow. -Right-click the curve and choose **Edit Equation Curve** to change the formulas -(`sketch.equation_curve` and `sketch.equation_curve.edit` from scripts and agents). +t, or y = f(x) over a range of x, using the design's parameters. [Equation curves](equation-curves.md) +walks through it step by step, with pictures and examples (a line, a parabola, a wave, a circle, +a spiral, a gear tooth's involute). Right-click the curve and choose **Edit Equation Curve** to +change the formulas (`sketch.equation_curve` and `sketch.equation_curve.edit` from scripts and agents). While drawing in a planar sketch, **Construction** (X) changes the line type without ending the tool or losing picked points and inline values. The preview and hover feedback diff --git a/docs/book/src/guide/troubleshooting.md b/docs/book/src/guide/troubleshooting.md index edfe0c9b49..685ee7ca21 100644 --- a/docs/book/src/guide/troubleshooting.md +++ b/docs/book/src/guide/troubleshooting.md @@ -158,9 +158,12 @@ When the graphics driver stops responding, the system resets it (on Windows, "Di stopped responding and has recovered"), and the graphics device SolidCraft draws with is gone for good: the window can't draw anything more. SolidCraft then closes, keeps your unsaved changes for recovery as it does after a crash, and starts again; the new window says what -happened and offers the changes back. If the device is reset again within ten minutes of that -restart, the next start uses software rendering, which doesn't depend on the graphics driver. -Updating the graphics driver from the GPU maker usually stops the resets. +happened, with what the graphics driver reported, and offers the changes back. The old window +closes on its own; SolidCraft doesn't wait for the driver to release it. If the device is reset +again within ten minutes of that restart, the next start uses software rendering, which doesn't +depend on the graphics driver. Updating the graphics driver from the GPU maker usually stops the +resets. Laptops that lose the device on waking from sleep (often with an NVIDIA GPU that doesn't +drive the screen) can keep SolidCraft on the integrated GPU instead: Preferences › Graphics. ## A design saved by 0.10 won't open in an older version diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index fd16d16ef7..db0225189b 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -20,9 +20,10 @@ design until you show them again. Hidden tabs keep their customised buttons, commands in search and shortcuts; with all tabs hidden, **+** remains. - **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 shows or - hides an item, 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 + 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, + 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 › Interface › Bodies folders** sets when a component's bodies sit in a Bodies folder of their own: @@ -34,10 +35,16 @@ reach something under something else (two lines drawn on top of each other, coincident points, an edge in front of a face, overlapping bodies), hold the left button still for half a second (long-press on a touch screen) or right-click › **Select Other…**: a list of everything under - the cursor opens there, the sketch being edited first. Hovering a row lights it up; clicking it + the cursor opens there, including hidden bodies and sketches near the pointer. + The popup grows to fit the available window space, opening above the pointer when it needs + the extra room, and scrolls only when the rows do not fit. Turn off **Select hidden geometry + with Select Other** in Preferences › Sketch & selection to restore the original visible-only + list under the pointer. Picking a hidden object leaves its + visibility unchanged, so an Extrude profile can be chosen through a covering body. + The sketch being edited comes first. Hovering a row lights it up; clicking it selects it (Shift or Ctrl adds), and in a command's dialog it goes to the active selection box. Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed - in between, selects the next thing in that list. The + in between, cycles through visible candidates at that point. The wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse preset in Preferences switches to SolidWorks, Inventor, Alias, Maya, Blender, Tinkercad, OpenSCAD, HFSS or Touchpad style). On a trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans; @@ -189,9 +196,22 @@ it sits on the left or under the browser and is wide enough for names. They are rows: the same eyes, folds and right-click menus, so one list is enough for single-part work. The History at the bottom is unchanged. +Interface › **Menus** › **Large icons** (the default is **List**) shows a section's +command menu as rows of large icons instead of a list of names. Point at an icon for its name, +shortcut and help; the arrow keys move across and between rows. Rest the pointer on an icon for +two seconds and a pin in its corner can be clicked to pin it to the ribbon or remove it (a +quicker click anywhere on the icon starts the command). Right-clicking the icon, or dragging it +onto the ribbon, also pins it; a pinned command always shows its pin. + +Interface › **Leave pinned commands out of the drop-down menus** (off by default) lists in a +section's menu only the commands that are not already buttons on the ribbon. When the window is +too narrow for a section's buttons, its menu lists them again. + ### Colour themes -Interface › **Theme** lists Dark, Light and System (follows the operating system), the built-in +Interface › **Theme** lists Dark, Light and System (follows the operating system, and changes +with it; on Linux it reads the desktop's dark or light style through the desktop portal, as +GNOME and KDE set it), the built-in themes and any themes you have imported. A choice applies at once. Under the themes, **Viewport** draws the 3D view in the Light or Dark theme's colours whatever the window uses, for example the model on a light view in a dark window (**Follow theme**, the default, keeps the theme's own). **Logo** draws the SolidCraft logo at the top left in shades of the theme's accent instead of the theme's logo colours. The built-in themes are Gruvbox (dark and light, each in hard, normal and soft contrast), SolidCraft 95, Platinum 9, Cupertino, Green Phosphor, Amber Phosphor, Blueprint, One Dark, Tokyo Night, Solarized Light, Nord, High diff --git a/docs/book/src/guide/updates.md b/docs/book/src/guide/updates.md index 76df4de2eb..96041e4475 100644 --- a/docs/book/src/guide/updates.md +++ b/docs/book/src/guide/updates.md @@ -22,6 +22,11 @@ up straight away, and again once a day while it stays open. **Preferences ▸ General ▸ Updates** has **Check for updates automatically** (on by default) and **Check now**, which checks right away and shows the window even for a version you skipped. +**Help ▸ Check for updates** runs the same check, including from the macOS menu bar. The window +shows progress, offers the installation or download when a newer version exists, or says +**SolidCraft is up to date.** If the check fails, it shows the error instead. Manual checks work +even when automatic checks are turned off; builds that cannot check explain that in the window. + ## Updating in one click Where SolidCraft can replace itself safely, the window also has **Update now**: diff --git a/docs/book/src/reference/glossary.md b/docs/book/src/reference/glossary.md index 8371981e40..004e28c880 100644 --- a/docs/book/src/reference/glossary.md +++ b/docs/book/src/reference/glossary.md @@ -324,6 +324,32 @@ The first instance is the original. - **Elsewhere:** instance, element, occurrence. - **For translators:** not an occurrence of a component. +### personal sheet metal rule + +A sheet metal rule kept in the user's preferences instead of a design, ready to add to any design. + +Picking one in Sheet Metal Rules fills the rule manager, and its OK copies the rule into the +design, which then keeps a copy of its own. It is an app setting, not part of a design. + +- **In SolidCraft:** Sheet Metal Rules › Personal rules (`sheet.manage_rules`); the + `personal_sheet_rules` key of the stored preferences. +- **Don't use:** preset, template, library rule. +- **Elsewhere:** rule library, sheet metal style library. +- **For translators:** "personal" as in the user's own, not private or confidential. + +### print face + +The flat face of a body that lies on the print bed when the body is sent to a slicer. + +A body has one print face at most. The export turns a copy of the body so this face points down +at Z = 0, with the centre of mass over the origin; the design itself does not move. + +- **In SolidCraft:** `body.print_face`; the `print_faces` of a design; the `print_faces` and + `warnings` of a `file.send_to_slicer` reply. +- **Don't use:** bottom face, base face (both say where a face is now, not where it is printed). +- **Elsewhere:** place on face, lay flat, bed face. +- **For translators:** the face the part stands on while it prints, not the printer's bed. + ### reference edge A straight edge of a flat face that a Single hole's centre or a cylinder's base centre keeps a @@ -497,6 +523,6 @@ reports a different name (`EdgeFlangeFeature`), so a script can't match one agai ## Open questions for the maintainer -- **British or American spelling** for labels and docs. Today both are in use (colour and color, - centre and center, favourite and favorite); ids and keys use American spelling. #2501 proposes - an English (UK) language option, so either base spelling can be read in the other. +- **British or American spelling** in the docs. The labels are settled: `en.json` is American + English, and English (UK) (`en-GB.json`, #2501) holds the British spelling. The docs still use + both (colour and color, centre and center); ids and keys use American spelling. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dd579dd77e..b5d560ae05 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -2,10 +2,20 @@ For UI-kit review, `ui.status_fixture {"preset":"mode"}` shows bounded status specimens in the real viewport. Presets are `off`, `mode`, `result`, `attention`, -`row`, `kinds` and `key`; the reply carries the preset and item count. This +`row`, `kinds`, `many` and `key`; the reply carries the preset and item count. This scenario fixture does not change engine state or open a dialog. `off` clears it. The published `viewport_status::item`, `:setting`, `:key` and `:close` handles support the ordinary `ui.at`, `ui.move`, `ui.click` and keyboard controls. +With `viewport_status_summary` enabled, `kinds` (three) and `many` (four) exercise +the summary component, using explicit specimen newest metadata. The +`viewport_status:summary` and `menu:viewport_status_clear_all` handles open its +native menu and activate its footer. These specimens are not persistent engine +results; actual production sources currently supply at most three Mode items. + +Toasts the user may close have a close (×) at their top right instead of a Close button, published +as `toast::close`: `toast:status:close`, `toast:space_mouse_offer:close` and +`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. 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 @@ -129,6 +139,12 @@ turn behind another. `command` is the command id of `engine.execute` (a known on paths and expressions stay with the connection that sent them. `self` marks the asking connection's own request. `{"running": []}` when the app is idle. +`control.capabilities` says what this app's control channel does beyond the methods every +version has, so a client can find out before it relies on it: `{"param_check": ["off", "warn", +"strict"]}`, the levels a request's own `param_check` takes (below). It is answered at once, like +`control.running`, also on a `busy` connection. An app older than it answers ``unknown method +`control.capabilities` ``; a feature added later is listed here too. + Any request's `params` may carry `allow_paths: ["/abs/folder", …]`: every file that request reads, writes or deletes (command paths such as `file.export`'s `path` or `doc.recovery_*`'s `dir`, `ui.render`, `ui.screenshot`) must then lie inside one of those folders after `..` and @@ -140,6 +156,20 @@ or saved a design in; each counts on the open design folder's terms. The bridged MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Without `allow_paths` file paths are not limited: a client holding the token can do what the user can. +`engine.execute`, `engine.batch` and `engine.script` may carry `param_check: "off" | "warn" | +"strict"` in their `params`: the commands of that request are checked against their typed +contracts at that level, whatever the app's own (which is `off`), as a headless session's +`--param-check` checks them (#9184). `warn` runs a call that breaks its contract and adds +`param_warnings` to its result; `strict` refuses it (a batch before its first step), and the +design is left as it was. Every reply to such a request says the level it was checked at as +`param_check`, beside `ok`. A command that switches to another design (`component.edit_in_place`, +`component.finish_edit`, `view.next_tab`, `view.previous_tab`) has its parameters checked at +that level before it runs, and then runs at the app's own level, as each design keeps its own. +Any other word is refused; other methods ignore the field. An app older than this ignores the field and its reply has no +`param_check`, so a client that needs the check asks `control.capabilities` first and treats a +reply without `param_check` as unchecked. The bridged MCP server asks once per connection +([mcp.md](mcp.md)). + ## Engine | Method | Params | Result | @@ -334,7 +364,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `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 | -| `ui.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list everything pickable there (as holding the left button does); `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `total`: everything under the cursor, `rows`: label, icon, sel, at most 20 per kind, nearest first) | +| `ui.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list compatible hidden and visible geometry near the pointer by default (as holding the left button does; disable `select_other_hidden` for the original visible-only list); the popup fits the available window space and scrolls only for overflow; `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `total`: everything under the cursor, `rows`: label, icon, sel, at most 20 per kind, nearest first) | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`), and finished sketches' dimensions shown in the model view (`shown_dimensions`: sketch, id and text centre; a double-click there edits the value) | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points | @@ -343,7 +373,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input | | `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | | `ui.pick` | `pixels?: [[x,y]…]`, `rays?: [{origin, dir}…]`, `frame?: "window"\|"model"`, `width?`, `height?`, `edges?` (default true), `edge_px?` (default 6), `all?`, `include_ghosted?` | read-only geometry under each pixel or ray (#6304): one entry per input, `null` where nothing is hit — body, face index, exact world `point` and outward `normal`, `depth`, `occurrence`/`component` for placed parts, `edge`/`vertex` within `edge_px` of a pixel, `section_cap: true` on a cut face. `window` pixels are physical pixels of a window screenshot; `model` pixels are pixels of a `ui.render` of `width`×`height` (default 960×600), so it works headless. As `view.pick` through `engine.execute`/`engine.batch`. See `crates/ui-egui/src/view_pick.rs` | -| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:` (the button alone: `button:`; a drop-down row: `dropdown:`, highlighted `dropdown_hot:`, its pin `dropdown_pin:`; Customize Toolbar's check boxes `customize:`), ribbon tabs `tab:` (MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW, SHEET METAL, WELDMENTS; contextual SKETCH; workspace RENDER), the toolbar menu button `ribbon:add_tab`, the hint when every tab is hidden `ribbon:tabs_hidden`, Preferences tab check boxes `prefs:toolbar_tab:`, panel labels `panel::` (also `ribbon:section::
`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row: