From 8497abecfd2625461738f87e821fedcb0940ab03 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 8 Sep 2026 08:34:58 +0200 Subject: [PATCH 01/40] Fix ribbon command labels wrapping in the middle of words Replace Wrapping::WordOrGlyph with Wrapping::Word to prevent labels like 'Dimensions' from breaking as 'Dimensio' + 'ns'. Word-only wrapping ensures labels only wrap at word boundaries. Fixes #1046 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ui/ribbon/widgets.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 333147351..7d9276c40 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -132,7 +132,7 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 { renderer, label, width, - advanced_text::Wrapping::WordOrGlyph, + advanced_text::Wrapping::Word, ) .height <= max_label_height @@ -685,7 +685,7 @@ pub(super) fn render_large_dropdown<'a>( .size(10) .width(Fill) .align_x(iced::Center) - .wrapping(advanced_text::Wrapping::WordOrGlyph), + .wrapping(advanced_text::Wrapping::Word), ] .align_x(iced::Center) .spacing(0) @@ -818,7 +818,7 @@ pub(super) fn render_large<'a>( .size(10) .width(Fill) .align_x(iced::Center) - .wrapping(advanced_text::Wrapping::WordOrGlyph), + .wrapping(advanced_text::Wrapping::Word), ] .align_x(iced::Center) .spacing(0) From 7a706e24acf6acdef8b82e25f6a051e1c5f9ba7c Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 8 Sep 2026 08:52:18 +0200 Subject: [PATCH 02/40] Improve Dutch CAD terminology translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multiverwijslijn → Multiverwijs Lijn (proper spacing) - Multiline → Multilijn Tekst (better clarity) - Unie → Samenvoegen (more accurate for boolean union) - Aftrekken → Verschil (better term for boolean subtract) These terms align better with Dutch CAD industry standards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- locales/nl-NL/opencadstudio.ftl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 93a868eef..08dc9dfc3 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2771,7 +2771,7 @@ annotate = .mleader = MLeader .mtext-specify-insertion-point = MTEXT Plaatsingspunt specificeren: .markup = Markering - .multileader = Multiverwijslijn + .multileader = Multiverwijs Lijn .multileader-draw-order = Tekenvolgorde multiverwijslijn: .multiline-text = Multiline @@ -3142,7 +3142,7 @@ model = .revolve-entity = Rotatieobject .revolve-flags = Vlaggen omdraaien .spherical = Sferisch - .subtract = Aftrekken + .subtract = Verschil .surface-construction = Oppervlaktebouw .sweep-path = Vegen / Pad .sweep-entity = Sweepobject @@ -3154,7 +3154,7 @@ model = .u-vector-1 = U-vector 1 .u-vector-2 = U-vector 2 .uid = UID - .union = Unie + .union = Samenvoegen .v-isolines = V-isolijnen .v-vector-1 = Vector 1 .v-vector-2 = Vector 2 @@ -3879,9 +3879,9 @@ properties = .mode = Modus .monochrome = Monochroom .more-colors = Meer kleuren... - .multileader = Multiverwijslijn - .multiline = Multiline - .multileader-style = Multiverwijslijnstijl + .multileader = Multiverwijs Lijn + .multiline = Multilijn Tekst + .multileader-style = Multiverwijs Lijnstijl .multiple-lines = Meerdere regels .n-closed = N gesloten .n-density = N-dichtheid From 92ac3f60e5e590f0a057365a367ba8a8b2b14e65 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 8 Sep 2026 09:04:52 +0200 Subject: [PATCH 03/40] Fix crash when overwriting an existing PDF file Use atomic file operations (write to temp file, then rename) instead of direct File::create() to avoid crashes when the target PDF is locked by another process (e.g., open in PDF reader). The new write_pdf_atomically() function ensures the file is either fully written or left unchanged, with a clear error message if the file is in use. Fixes #1062 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/io/pdf_export.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 0ff656d13..88911fd21 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -175,8 +175,7 @@ pub fn export_pdf( plot_style, options, ); - let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; - file.write_all(&bytes).map_err(|e| e.to_string()) + write_pdf_atomically(path, &bytes) } /// Export several independently sized pages into one PDF file. @@ -190,8 +189,27 @@ pub fn export_pdf_pages( return Err("No pages were selected.".into()); } let bytes = build_pdf_pages(pages, plot_style); - let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; - file.write_all(&bytes).map_err(|e| e.to_string()) + write_pdf_atomically(path, &bytes) +} + +/// Atomically write PDF bytes to a file. Writes to a temporary file first, +/// then renames it to the target path to avoid crashes when overwriting +/// a file that is locked by another process (e.g., PDF reader). +#[cfg(not(target_arch = "wasm32"))] +fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { + let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let file_name = path.file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid file path".to_string())?; + let temp_path = dir.join(format!(".{}.tmp", file_name)); + + let mut file = std::fs::File::create(&temp_path) + .map_err(|e| format!("Failed to create temporary PDF file: {}", e))?; + file.write_all(bytes) + .map_err(|e| format!("Failed to write PDF data: {}", e))?; + + std::fs::rename(&temp_path, path) + .map_err(|e| format!("Failed to write PDF file: {} (the file may be open in another application)", e)) } /// Show a parented PDF save-file dialog and return the chosen path. From 725a7b3e7e18a67acbb2004fd804c993a7d7ecad Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 8 Sep 2026 09:26:28 +0200 Subject: [PATCH 04/40] Align ByLayer property dropdowns consistently in Properties panel Use SELECTOR_PAD_V (calculated from ROW_H and FONT_SZ) instead of hardcoded [3, 6] padding for color selector buttons. This matches the padding used by combo_box widgets for Lineweight and Linetype, ensuring all three ByLayer dropdowns align vertically and have consistent height. Fixes #1047 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ui/color_select.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index 054d80812..82a41c6f8 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -5,11 +5,17 @@ use crate::app::Message; use crate::ui::properties::acad_color_display; +use crate::ui::ROW_H; use acadrust::types::Color as AcadColor; use iced::widget::{button, column, container, row, text}; -use iced::{Background, Border, Color, Element, Length, Theme}; +use iced::{Background, Border, Color, Element, Length, Padding, Theme}; use crate::t; +/// Font size for color selector buttons (matches combo_box styling). +const FONT_SZ: f32 = ROW_H * 0.42; +/// Vertical padding for color selector buttons (matches combo_box styling). +const SELECTOR_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; + /// Which "logical" entries the colour list offers besides the standard ACI /// colours. #[derive(Clone, Copy, Default)] @@ -222,7 +228,12 @@ fn color_selector_with_indicator<'a>( .align_y(iced::Center), ) .on_press(on_toggle) - .padding([3, 6]) + .padding(Padding { + top: SELECTOR_PAD_V, + bottom: SELECTOR_PAD_V, + left: 6.0, + right: 6.0, + }) .width(Length::Fill); if !open { From 21a357f55a9ed98d9c05591540a8fbe11b1cb209 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:27:23 +0300 Subject: [PATCH 05/40] Complete SLICE command behavior --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- locales/en-US/opencadstudio.ftl | 33 +++ locales/tr-TR/opencadstudio.ftl | 33 +++ src/app/command_driver.rs | 28 ++ src/app/commands/draw.rs | 71 ++++- src/app/model_ops.rs | 316 ++++++++++++++++---- src/command.rs | 14 + src/locale_catalog.rs | 33 +++ src/modules/model/mod.rs | 1 + src/modules/model/slice_cmd.rs | 510 ++++++++++++++++++++++++++++++++ 12 files changed, 969 insertions(+), 76 deletions(-) create mode 100644 src/modules/model/slice_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 326bb7474..a164e9172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192" +source = "git+https://github.com/ramox81/cadkernel.git?rev=70aa5178c59d277b14c13dddffa502633d4cfbbc#70aa5178c59d277b14c13dddffa502633d4cfbbc" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 21a3ba4a8..8d0e8bdf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "70aa5178c59d277b14c13dddffa502633d4cfbbc", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index a5476d897..62d0d4198 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "70aa5178c59d277b14c13dddffa502633d4cfbbc", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6c97312fe..b5afeffd9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -767,6 +767,19 @@ common = .slice-cut-at-ax-value-kept-the-half = SLICE: cut at __ocs_fmt_0__=__ocs_fmt_1__, kept the __ocs_fmt_2__ half. .slice-select-exactly-one-solid-created-this-se = SLICE: select exactly one solid created this session. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: the plane does not cross the solid on the kept side. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: fit a spline through __ocs_fmt_0__ points. .splinefit-need-at-least-3-points = SPLINEFIT: need at least 3 points. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: select a polyline to fit a spline through. @@ -2196,6 +2209,26 @@ draw = .slice-offset-along-x = SLICE offset along X: .slice-offset-along-y = SLICE offset along Y: .slice-offset-along-z = SLICE offset along Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Specify first point: .solid-specify-fourth-point = SOLID Specify fourth point: .solid-specify-second-point = SOLID Specify second point: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 69705fee8..2bf9cde7f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -754,6 +754,19 @@ common = .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__ konumunda kesildi, __ocs_fmt_2__ yarısı korundu. .slice-select-exactly-one-solid-created-this-se = SLICE: Bu oturumda oluşturulan tam bir katı seçin. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Düzlem, korunan tarafta katıyı kesmiyor. + .both = Her ikisi + .slice-select-at-least-one-target = SLICE: En az bir katı veya yüzey seçin. + .slice-invalid-plane = SLICE: Kesme düzlemi geçersiz. + .slice-side-point-on-plane = SLICE: Kesme düzleminden uzakta bir nokta seçin. + .slice-plane-misses-target = SLICE: Kesme düzlemi seçilen her nesneyi kesmiyor; hiçbir nesne değiştirilmedi. + .slice-unsupported-intersection = SLICE: Seçilen geometri desteklenmeyen bir yüzey kesişimi içeriyor; hiçbir nesne değiştirilmedi. + .slice-coincident-plane = SLICE: Kesme düzlemi kaynak geometriyle çakışıyor; hiçbir nesne değiştirilmedi. + .slice-topology-refused = SLICE: Seçilen topoloji güvenle bölünemedi; hiçbir nesne değiştirilmedi. + .slice-result-encode-failed = SLICE: Bir sonuç kodlanamadı; hiçbir nesne değiştirilmedi. + .slice-result-display-failed = SLICE: Bir sonuç eksiksiz görüntülenemedi; hiçbir nesne değiştirilmedi. + .slice-result-commit-failed = SLICE: Bir sonuç işlenemedi; tüm kaynak nesneler korundu. + .slice-retained-update-failed = SLICE: Korunan sonuç güncellenemedi. + .slice-created-results = SLICE: __ocs_fmt_0__ sonuç nesnesi oluşturuldu. .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ noktadan geçen bir spline uyduruldu. .splinefit-need-at-least-3-points = SPLINEFIT: En az 3 nokta gerekir. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: İçinden spline geçirilecek bir çoklu çizgi seçin. @@ -2177,6 +2190,26 @@ draw = .slice-offset-along-x = SLICE X boyunca ofset: .slice-offset-along-y = SLICE Y boyunca ofset: .slice-offset-along-z = SLICE Z boyunca ofset: + .slice-select-targets = SLICE Dilimlenecek katıları veya yüzeyleri seçin, ardından Enter'a basın: + .slice-first-point-or-method = SLICE Kesme düzlemindeki ilk noktayı belirtin veya [düzlemsel Nesne/Yüzey/Zekseni/Görünüm/XY/YZ/ZX/3nokta] <3nokta>: + .slice-first-plane-point = SLICE Kesme düzlemindeki ilk noktayı belirtin: + .slice-second-plane-point = SLICE Kesme düzlemindeki ikinci noktayı belirtin: + .slice-third-plane-point = SLICE Kesme düzlemindeki üçüncü noktayı belirtin: + .slice-point-on-plane = SLICE Kesme düzleminde bir nokta belirtin: + .slice-point-on-normal-axis = SLICE Düzlemin normal ekseninde bir nokta belirtin: + .slice-point-on-view-plane = SLICE Geçerli görünüm düzleminde bir nokta belirtin: + .slice-point-on-ucs-plane = SLICE Geçerli UCS düzleminde bir nokta belirtin: + .slice-select-planar-object = SLICE Düzlemsel bir eğri nesnesi seçin: + .slice-select-planar-surface = SLICE Düzlemsel bir yüzey seçin: + .slice-select-cutting-surface = SLICE Bir kesme yüzeyi seçin: + .slice-select-side-or-both = SLICE İstenen tarafta bir nokta belirtin veya [Her ikisi] : + .slice-select-object-or-both = SLICE Korunacak dilimlenmiş nesneyi seçin veya [Her ikisi] : + .slice-surface-not-planar = SLICE Seçilen yüzey düzlemsel değil. Düzlemsel bir yüzey seçin: + .slice-surface-not-supported = SLICE Seçilen yüzey desteklenen tek bir kesme yüzeyi tanımlamıyor. Başka bir yüzey seçin: + .slice-object-not-planar = SLICE Seçilen nesne bir düzlem tanımlamıyor. Düzlemsel bir eğri nesnesi seçin: + .slice-planar-object = düzlemsel Nesne + .slice-zaxis = Zekseni + .slice-three-points = 3nokta .solid-specify-first-point = SOLID İlk noktayı belirtin: .solid-specify-fourth-point = SOLID Dördüncü noktayı belirtin: .solid-specify-second-point = SOLID İkinci noktayı belirtin: diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b74e15fd..6ec45460b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -734,6 +734,8 @@ impl OpenCADStudio { CmdResult::Relaunch(..) | CmdResult::Dispatch(..) | CmdResult::SolidSubtract { .. } + | CmdResult::SliceEntities { .. } + | CmdResult::SliceSurfaceEntities { .. } ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; @@ -4128,6 +4130,32 @@ impl OpenCADStudio { return task; } + CmdResult::SliceEntities { + targets, + plane, + keep_point, + } => { + let task = self.solid_slice(&targets, plane, keep_point); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + + CmdResult::SliceSurfaceEntities { + targets, + cutter, + keep_point, + } => { + let task = self.solid_slice_surface(&targets, *cutter, keep_point); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::HatcheditApply { handle, name, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 3a2b4a131..90eda4823 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1283,21 +1283,51 @@ impl OpenCADStudio { } } - // SLICE [X|Y|Z] [TOP|BOTTOM] — cut the selected solid with an - // axis-aligned plane, keeping the lower half by default. "SLICE" | "SL" => { - use crate::command::SelectThenKeywordCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenKeywordCommand::new( - "SLICE", - "SLICE cutting-plane axis [X / Y / Z] (add TOP/BOTTOM by typing):", - vec![ - ("X", "X", Some("SLICE offset along X:")), - ("Y", "Y", Some("SLICE offset along Y:")), - ("Z", "Z", Some("SLICE offset along Z:")), - ], - has_sel, - ); + use crate::modules::model::slice_cmd::SliceCommand; + let (targets, centre, radius, view_normal) = { + let scene = &mut self.tabs[i].scene; + let mut targets = scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some( + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Surface(_) + ) + ) + }) + .collect::>(); + scene.restore_solid_models(&targets); + targets.retain(|handle| scene.solid_models.contains_key(handle)); + let bounds = targets + .iter() + .filter_map(|handle| { + crate::scene::model::solid_model::extent(&scene.solid_models[handle]) + }) + .fold(None::<([f64; 3], [f64; 3])>, |bounds, (low, high)| { + Some(match bounds { + None => (low, high), + Some((mut min, mut max)) => { + for axis in 0..3 { + min[axis] = min[axis].min(low[axis]); + max[axis] = max[axis].max(high[axis]); + } + (min, max) + } + }) + }); + let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { + let min = glam::DVec3::from_array(min); + let max = glam::DVec3::from_array(max); + ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) + }); + (targets, centre, radius, scene.active_gaze_dir().as_dvec3()) + }; + let c = SliceCommand::new(targets, view_normal, centre, radius); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1316,7 +1346,18 @@ impl OpenCADStudio { let value: Option = parts.get(val_idx).and_then(|s| s.parse().ok()); let keep_low = !parts.iter().any(|s| s == "TOP"); match value { - Some(v) => return Some(self.solid_slice(axis, v, keep_low)), + Some(v) => { + let (origin, x, normal) = match axis { + 0 => ([v, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), + 1 => ([0.0, v, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + _ => ([0.0, 0.0, v], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + }; + let plane = cadkernel::space::Plane::orthonormal(origin, x, normal) + .expect("fixed world axes define a plane"); + let side = glam::DVec3::from_array(origin) + + glam::DVec3::from_array(normal) * if keep_low { -1.0 } else { 1.0 }; + return Some(self.slice_selected(plane, Some(side))); + } None => self.command_line.push_info( crate::t!("Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)").as_ref(), ), diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a262c0ee3..813a54f6f 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -76,6 +76,35 @@ struct PreparedSubtract { result: Option<(Handle, EntityType, Body, PreparedSolidDisplay)>, } +struct PreparedSlice { + retained: Handle, + entity: EntityType, + body: Body, + display: PreparedSolidDisplay, + extra: Option<(EntityType, Body)>, +} + +enum ModelSliceTool { + Plane(cadkernel::space::Plane), + Surface(Body), +} + +impl ModelSliceTool { + fn side(&self, point: [f64; 3]) -> Option { + match self { + Self::Plane(plane) => plane.distance_to(point), + Self::Surface(surface) => cadkernel::brep::surface_side(surface, point), + } + } + + fn split(&self, body: &Body) -> Result, cadkernel::brep::Snag> { + match self { + Self::Plane(plane) => cadkernel::brep::slice_by_plane(body, *plane), + Self::Surface(surface) => cadkernel::brep::slice_by_surface(body, surface), + } + } +} + struct IntersectGroup { kind: UnionEntityKind, plane: Option, @@ -1273,77 +1302,248 @@ impl super::OpenCADStudio { Task::none() } - /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = - /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The - /// kept half is the intersection of the solid with a half-space box, reusing - /// the same boolean path as the modelling tools. - pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { + /// Split every selected solid or surface by an arbitrary plane. All + /// geometry, persistence records, and displays are prepared before the + /// first source entity is changed. + pub(super) fn solid_slice( + &mut self, + requested: &[Handle], + plane: cadkernel::space::Plane, + keep_point: Option, + ) -> Task { + self.solid_slice_with_tool(requested, ModelSliceTool::Plane(plane), keep_point) + } + + pub(super) fn solid_slice_surface( + &mut self, + requested: &[Handle], + cutter: Body, + keep_point: Option, + ) -> Task { + self.solid_slice_with_tool(requested, ModelSliceTool::Surface(cutter), keep_point) + } + + fn solid_slice_with_tool( + &mut self, + requested: &[Handle], + tool: ModelSliceTool, + keep_point: Option, + ) -> Task { let i = self.active_tab; - let handles = self.selected_solid_handles(); - if handles.len() != 1 { - self.command_line - .push_error(crate::t!("SLICE: select exactly one solid created this session.").as_ref()); + let mut handles = requested + .iter() + .copied() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_) | EntityType::Surface(_)) + ) + }) + .collect::>(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + if handles.is_empty() { + self.command_line.push_error( + crate::t!("SLICE: select at least one solid or surface.").as_ref(), + ); return Task::none(); } - let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - // Bounding box from the solid's edge wires. - let Some((min, max)) = solid_model::extent(&solid) else { - self.command_line - .push_error(crate::t!("SLICE: could not determine the solid's extent.").as_ref()); - return Task::none(); - }; - // Generous margin so the box fully spans the solid in the free axes. - let m = [ - (max[0] - min[0]).max(1.0), - (max[1] - min[1]).max(1.0), - (max[2] - min[2]).max(1.0), - ]; - let mut lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]]; - let mut hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]]; - if keep_low { - hi[axis] = value; + + let keep_positive = if let Some(point) = keep_point { + let tolerance = cadkernel::brep::operation_tolerance( + &handles + .iter() + .map(|handle| &self.tabs[i].scene.solid_models[handle]) + .collect::>(), + ); + let Some(distance) = tool.side(point.to_array()) else { + self.command_line + .push_error(crate::t!("SLICE: the cutting plane is invalid.").as_ref()); + return Task::none(); + }; + if distance.abs() <= tolerance { + self.command_line.push_error( + crate::t!("SLICE: choose a point away from the cutting plane.").as_ref(), + ); + return Task::none(); + } + Some(distance > 0.0) } else { - lo[axis] = value; - } - if hi[axis] <= lo[axis] { - self.command_line - .push_error(crate::t!("SLICE: the plane does not cross the solid on the kept side.").as_ref()); - return Task::none(); - } - let center = [ - (lo[0] + hi[0]) / 2.0, - (lo[1] + hi[1]) / 2.0, - (lo[2] + hi[2]) / 2.0, - ]; - let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]); - let Some(result) = halfspace - .as_ref() - .and_then(|half| solid_model::boolean(Bool::Intersect, &solid, half)) - else { - self.command_line - .push_error(crate::t!("SLICE failed — the plane may not cross the solid.").as_ref()); - return Task::none(); + None }; + + let mut prepared = Vec::with_capacity(handles.len()); + for handle in &handles { + let body = &self.tabs[i].scene.solid_models[handle]; + let result = match tool.split(body) { + Ok(Some(result)) => result, + Ok(None) => { + self.command_line.push_error( + crate::t!("SLICE: the cutting plane does not cross every selected object; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("SLICE: the selected geometry has an unsupported surface intersection; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("SLICE: the cutting plane is coincident with source geometry; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("SLICE: the selected topology could not be split safely; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + }; + let (body, extra_body) = match keep_positive { + Some(true) => (result.positive, None), + Some(false) => (result.negative, None), + None => (result.negative, Some(result.positive)), + }; + let Some(source) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + return Task::none(); + }; + let Some(entity) = entity_with_boolean_body(source.clone(), &body) else { + self.command_line.push_error( + crate::t!("SLICE: a result could not be encoded; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(*handle, &body) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("SLICE: a result could not be displayed completely; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + let extra = match extra_body { + Some(extra_body) => { + let mut extra_entity = match entity_with_boolean_body(source, &extra_body) { + Some(entity) => entity, + None => { + self.command_line.push_error( + crate::t!("SLICE: a result could not be encoded; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + }; + extra_entity.common_mut().handle = Handle::NULL; + Some((extra_entity, extra_body)) + } + None => None, + }; + prepared.push(PreparedSlice { + retained: *handle, + entity, + body, + display, + extra, + }); + } + self.push_undo_snapshot(i, "SLICE"); - self.tabs[i].scene.erase_entities(&handles); - let mut s3d = Solid3D::new(); - s3d.wires = solid_model::edge_wires(&result); - let history = solid_history::brep_op(&result); - let handle = self.add_solid_model(EntityType::Solid3D(s3d), result, history); + let mut created = Vec::new(); + for item in &prepared { + let Some((entity, body)) = &item.extra else { + continue; + }; + let handle = match entity { + EntityType::Solid3D(_) => self.add_solid_model_preserving_style( + entity.clone(), + body.clone(), + solid_history::brep_op(body), + ), + EntityType::Surface(_) => { + self.add_surface_model_preserving_style(entity.clone(), body.clone()) + } + _ => Handle::NULL, + }; + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + self.command_line.push_error( + crate::t!("SLICE: a result could not be committed; all source objects were retained.") + .as_ref(), + ); + return Task::none(); + } + created.push(handle); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + let mut retained = Vec::with_capacity(prepared.len()); + for item in prepared { + self.tabs[i].scene.delete_solid_history(item.retained); + if !self.tabs[i].scene.update_entity(item.entity) { + self.command_line.push_error( + crate::t!("SLICE: a retained result could not be updated.").as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(item.retained), + Some(EntityType::Solid3D(_)) + ) + { + self.tabs[i] + .scene + .create_solid_history(item.retained, solid_history::brep_op(&item.body)); + } + self.tabs[i].scene.register_prepared_solid_model( + item.retained, + item.body, + item.display, + ); + retained.push(item.retained); + } + + retained.extend(created); self.tabs[i].scene.deselect_all(); - if !handle.is_null() { - self.tabs[i].scene.select_entity(handle, false); + for handle in &retained { + self.tabs[i].scene.select_entity(*handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); - let ax = ["X", "Y", "Z"][axis]; - self.command_line.push_output(crate::tf!( - "SLICE: cut at {ax}={value}, kept the {} half.", - if keep_low { "lower" } else { "upper" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SLICE: created %{count} result object(s).", + count = retained.len() + ) + .as_ref(), + ); Task::none() } + pub(super) fn slice_selected( + &mut self, + plane: cadkernel::space::Plane, + keep_point: Option, + ) -> Task { + let handles = self.tabs[self.active_tab].scene.selected_handles_in_order(); + self.solid_slice(&handles, plane, keep_point) + } + /// INTERFERE — create a solid from the overlap of the two selected solids, /// leaving the originals in place (a non-destructive boolean intersect). pub(super) fn solid_interfere(&mut self) -> Task { diff --git a/src/command.rs b/src/command.rs index 25ccfb027..fbe096345 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1559,6 +1559,20 @@ pub enum CmdResult { cutters: Vec, convert_meshes: bool, }, + /// Split every selected solid or surface with one arbitrary plane. + /// `keep_point == None` retains both sides; otherwise it selects the side + /// containing that WCS point. + SliceEntities { + targets: Vec, + plane: cadkernel::space::Plane, + keep_point: Option, + }, + /// Split selected solids or surfaces with one selected analytic sheet. + SliceSurfaceEntities { + targets: Vec, + cutter: Box, + keep_point: Option, + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index eda54347e..946940da2 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2346,11 +2346,44 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SLICE offset along X:" => Some(("draw", "slice-offset-along-x")), "SLICE offset along Y:" => Some(("draw", "slice-offset-along-y")), "SLICE offset along Z:" => Some(("draw", "slice-offset-along-z")), + "SLICE Select solids or surfaces to slice, then press Enter:" => Some(("draw", "slice-select-targets")), + "SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:" => Some(("draw", "slice-first-point-or-method")), + "SLICE Specify first point on slicing plane:" => Some(("draw", "slice-first-plane-point")), + "SLICE Specify second point on slicing plane:" => Some(("draw", "slice-second-plane-point")), + "SLICE Specify third point on slicing plane:" => Some(("draw", "slice-third-plane-point")), + "SLICE Specify a point on the slicing plane:" => Some(("draw", "slice-point-on-plane")), + "SLICE Specify a point on the plane's normal axis:" => Some(("draw", "slice-point-on-normal-axis")), + "SLICE Specify a point on the current view plane:" => Some(("draw", "slice-point-on-view-plane")), + "SLICE Specify a point on the current UCS plane:" => Some(("draw", "slice-point-on-ucs-plane")), + "SLICE Select a planar curve object:" => Some(("draw", "slice-select-planar-object")), + "SLICE Select a planar surface:" => Some(("draw", "slice-select-planar-surface")), + "SLICE Select a cutting surface:" => Some(("draw", "slice-select-cutting-surface")), + "SLICE Specify a point on the desired side or [Both] :" => Some(("draw", "slice-select-side-or-both")), + "SLICE Select a sliced object to keep or [Both] :" => Some(("draw", "slice-select-object-or-both")), + "SLICE The selected surface is not planar. Select a planar surface:" => Some(("draw", "slice-surface-not-planar")), + "SLICE The selected surface does not define one supported cutting sheet. Select another surface:" => Some(("draw", "slice-surface-not-supported")), + "SLICE The selected object does not define a plane. Select a planar curve object:" => Some(("draw", "slice-object-not-planar")), + "planar Object" => Some(("draw", "slice-planar-object")), + "Zaxis" => Some(("draw", "slice-zaxis")), + "3points" => Some(("draw", "slice-three-points")), + "Both" => Some(("common", "both")), "SLICE failed — the plane may not cross the solid." => Some(("common", "slice-failed-the-plane-may-not-cross-the-solid")), "SLICE: could not determine the solid's extent." => Some(("common", "slice-could-not-determine-the-solid-s-extent")), "SLICE: cut at {ax}={value}, kept the {} half." => Some(("common", "slice-cut-at-ax-value-kept-the-half")), "SLICE: select exactly one solid created this session." => Some(("common", "slice-select-exactly-one-solid-created-this-se")), "SLICE: the plane does not cross the solid on the kept side." => Some(("common", "slice-the-plane-does-not-cross-the-solid-on-th")), + "SLICE: select at least one solid or surface." => Some(("common", "slice-select-at-least-one-target")), + "SLICE: the cutting plane is invalid." => Some(("common", "slice-invalid-plane")), + "SLICE: choose a point away from the cutting plane." => Some(("common", "slice-side-point-on-plane")), + "SLICE: the cutting plane does not cross every selected object; no object was changed." => Some(("common", "slice-plane-misses-target")), + "SLICE: the selected geometry has an unsupported surface intersection; no object was changed." => Some(("common", "slice-unsupported-intersection")), + "SLICE: the cutting plane is coincident with source geometry; no object was changed." => Some(("common", "slice-coincident-plane")), + "SLICE: the selected topology could not be split safely; no object was changed." => Some(("common", "slice-topology-refused")), + "SLICE: a result could not be encoded; no object was changed." => Some(("common", "slice-result-encode-failed")), + "SLICE: a result could not be displayed completely; no object was changed." => Some(("common", "slice-result-display-failed")), + "SLICE: a result could not be committed; all source objects were retained." => Some(("common", "slice-result-commit-failed")), + "SLICE: a retained result could not be updated." => Some(("common", "slice-retained-update-failed")), + "SLICE: created %{count} result object(s)." => Some(("common", "slice-created-results")), "SOLID Specify first point:" => Some(("draw", "solid-specify-first-point")), "SOLID Specify fourth point:" => Some(("draw", "solid-specify-fourth-point")), "SOLID Specify second point:" => Some(("draw", "solid-specify-second-point")), diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..d664d68f5 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -5,6 +5,7 @@ pub mod cylinder_cmd; pub mod edge_cmd; pub mod polysolid_cmd; pub mod primitive_cmd; +pub mod slice_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; diff --git a/src/modules/model/slice_cmd.rs b/src/modules/model/slice_cmd.rs new file mode 100644 index 000000000..b64909bb8 --- /dev/null +++ b/src/modules/model/slice_cmd.rs @@ -0,0 +1,510 @@ +use acadrust::{EntityType, Handle}; +use cadkernel::space::Plane; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, SelectionEntity, WorkingPlane}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy)] +enum PlaneKind { + Xy, + Yz, + Zx, + View, +} + +enum Step { + Targets, + FirstPoint, + ThreeFirst, + ThreeSecond(DVec3), + ThirdPoint(DVec3, DVec3), + AxisFirst, + AxisSecond(DVec3), + PlanePoint(PlaneKind), + PickObject, + PickSurface, + Side(Plane), + SurfaceSide, +} + +/// Interactive front-end for plane-based SLICE. +pub struct SliceCommand { + step: Step, + targets: Vec, + selected: Vec, + working: WorkingPlane, + view_normal: DVec3, + preview_center: DVec3, + preview_radius: f64, + picked: Option, + surface_tool: Option, + notice: Option<&'static str>, +} + +impl SliceCommand { + pub fn new( + targets: Vec, + view_normal: DVec3, + preview_center: DVec3, + preview_radius: f64, + ) -> Self { + let step = if targets.is_empty() { Step::Targets } else { Step::FirstPoint }; + Self { + step, + targets, + selected: Vec::new(), + working: WorkingPlane::default(), + view_normal: view_normal.normalize_or(DVec3::Z), + preview_center, + preview_radius: preview_radius.max(1.0), + picked: None, + surface_tool: None, + notice: None, + } + } + + fn plane(origin: DVec3, x: DVec3, normal: DVec3) -> Option { + Plane::orthonormal(origin.to_array(), x.to_array(), normal.to_array()) + } + + fn enter_side(&mut self, plane: Plane) -> CmdResult { + self.notice = None; + self.step = Step::Side(plane); + CmdResult::NeedPoint + } + + fn finish(&mut self, keep_point: Option) -> CmdResult { + let Step::Side(plane) = self.step else { + return CmdResult::NeedPoint; + }; + CmdResult::SliceEntities { + targets: std::mem::take(&mut self.targets), + plane, + keep_point, + } + } + + fn picked_plane(&self) -> Option { + let entity = self.picked.as_ref()?; + if !matches!( + entity, + EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline(_) + ) { + return None; + } + crate::scene::model::presspull_model::profile_geometry(entity) + .map(|(plane, _, _)| plane) + } + + fn finish_surface(&mut self, keep_point: Option) -> CmdResult { + let Some(cutter) = self.surface_tool.take() else { + return CmdResult::NeedPoint; + }; + CmdResult::SliceSurfaceEntities { + targets: std::mem::take(&mut self.targets), + cutter: Box::new(cutter), + keep_point, + } + } + + fn plane_at(&self, point: DVec3, kind: PlaneKind) -> Option { + let (x, normal) = match kind { + PlaneKind::Xy => (self.working.x, self.working.z), + PlaneKind::Yz => (self.working.y, self.working.x), + PlaneKind::Zx => (self.working.z, self.working.y), + PlaneKind::View => { + let candidate = if self.working.x.dot(self.view_normal).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + (candidate, self.view_normal) + } + }; + Self::plane(point, x, normal) + } +} + +impl CadCommand for SliceCommand { + fn name(&self) -> &'static str { + "SLICE" + } + + fn prompt(&self) -> String { + if let Some(notice) = self.notice { + return crate::t!(notice).into_owned(); + } + match self.step { + Step::Targets => crate::t!( + "SLICE Select solids or surfaces to slice, then press Enter:" + ) + .into_owned(), + Step::FirstPoint => crate::t!( + "SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:" + ) + .into_owned(), + Step::ThreeFirst => { + crate::t!("SLICE Specify first point on slicing plane:").into_owned() + } + Step::ThreeSecond(_) => { + crate::t!("SLICE Specify second point on slicing plane:").into_owned() + } + Step::ThirdPoint(_, _) => { + crate::t!("SLICE Specify third point on slicing plane:").into_owned() + } + Step::AxisFirst => { + crate::t!("SLICE Specify a point on the slicing plane:").into_owned() + } + Step::AxisSecond(_) => { + crate::t!("SLICE Specify a point on the plane's normal axis:").into_owned() + } + Step::PlanePoint(PlaneKind::View) => { + crate::t!("SLICE Specify a point on the current view plane:").into_owned() + } + Step::PlanePoint(_) => { + crate::t!("SLICE Specify a point on the current UCS plane:").into_owned() + } + Step::PickObject => crate::t!("SLICE Select a planar curve object:").into_owned(), + Step::PickSurface => crate::t!("SLICE Select a cutting surface:").into_owned(), + Step::Side(_) => crate::t!( + "SLICE Specify a point on the desired side or [Both] :" + ) + .into_owned(), + Step::SurfaceSide => crate::t!( + "SLICE Select a sliced object to keep or [Both] :" + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::FirstPoint => vec![ + CmdOption::new(crate::t!("planar Object").as_ref(), "O"), + CmdOption::new(crate::t!("Surface").as_ref(), "S"), + CmdOption::new(crate::t!("Zaxis").as_ref(), "Z"), + CmdOption::new(crate::t!("View").as_ref(), "V"), + CmdOption::new("XY", "XY"), + CmdOption::new("YZ", "YZ"), + CmdOption::new("ZX", "ZX"), + CmdOption::new(crate::t!("3points").as_ref(), "3"), + ], + Step::Side(_) | Step::SurfaceSide => { + vec![CmdOption::new(crate::t!("Both").as_ref(), "B")] + } + _ => Vec::new(), + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.working = plane; + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.notice = None; + match self.step { + Step::FirstPoint => { + self.step = Step::ThreeSecond(point); + CmdResult::NeedPoint + } + Step::ThreeFirst => { + self.step = Step::ThreeSecond(point); + CmdResult::NeedPoint + } + Step::ThreeSecond(first) => { + self.step = Step::ThirdPoint(first, point); + CmdResult::NeedPoint + } + Step::ThirdPoint(first, second) => { + let x = second - first; + let normal = x.cross(point - first); + Self::plane(first, x, normal) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)) + } + Step::AxisFirst => { + self.step = Step::AxisSecond(point); + CmdResult::NeedPoint + } + Step::AxisSecond(origin) => { + let normal = point - origin; + let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + Self::plane(origin, x, normal) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)) + } + Step::PlanePoint(kind) => self + .plane_at(point, kind) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)), + Step::Side(_) => self.finish(Some(point)), + Step::SurfaceSide => self.finish_surface(Some(point)), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Targets if !self.selected.is_empty() => { + self.targets = std::mem::take(&mut self.selected); + self.step = Step::FirstPoint; + CmdResult::DeselectAndContinue + } + Step::FirstPoint => { + self.step = Step::ThreeFirst; + CmdResult::NeedPoint + } + Step::Side(_) => self.finish(None), + Step::SurfaceSide => self.finish_surface(None), + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + Some(match self.step { + Step::FirstPoint => match keyword.as_str() { + "O" | "OBJECT" => { + self.step = Step::PickObject; + CmdResult::NeedPoint + } + "S" | "SURFACE" => { + self.step = Step::PickSurface; + CmdResult::NeedPoint + } + "Z" | "ZAXIS" => { + self.step = Step::AxisFirst; + CmdResult::NeedPoint + } + "V" | "VIEW" => { + self.step = Step::PlanePoint(PlaneKind::View); + CmdResult::NeedPoint + } + "XY" => { + self.step = Step::PlanePoint(PlaneKind::Xy); + CmdResult::NeedPoint + } + "YZ" => { + self.step = Step::PlanePoint(PlaneKind::Yz); + CmdResult::NeedPoint + } + "ZX" | "XZ" => { + self.step = Step::PlanePoint(PlaneKind::Zx); + CmdResult::NeedPoint + } + "3" | "3P" | "3POINTS" => { + self.step = Step::ThreeFirst; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + }, + Step::Side(_) if matches!(keyword.as_str(), "" | "B" | "BOTH") => self.finish(None), + Step::SurfaceSide if matches!(keyword.as_str(), "" | "B" | "BOTH") => { + self.finish_surface(None) + } + _ => CmdResult::NeedPoint, + }) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstPoint | Step::Side(_) | Step::SurfaceSide) + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, Step::Targets) + } + + fn selection_forces_add(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected = entities + .into_iter() + .filter_map(|item| { + matches!(item.entity, EntityType::Solid3D(_) | EntityType::Surface(_)) + .then_some(item.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + + fn needs_entity_pick(&self) -> bool { + matches!( + self.step, + Step::PickObject | Step::PickSurface | Step::SurfaceSide + ) + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickObject | Step::PickSurface) + } + + fn entity_pick_uses_surface_point(&self) -> bool { + matches!(self.step, Step::SurfaceSide) + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, _handle: Handle, _point: DVec3) -> CmdResult { + if matches!(self.step, Step::SurfaceSide) { + return self.finish_surface(Some(_point)); + } + let surface_only = matches!(self.step, Step::PickSurface); + if surface_only { + let body = self.picked.as_ref().and_then(|entity| match entity { + EntityType::Surface(surface) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } + _ => None, + }); + if let Some(body) = body { + if let Some(plane) = planar_body_plane(&body) { + return self.enter_side(plane); + } + if body.face_keys().count() == 1 { + self.surface_tool = Some(body); + self.notice = None; + self.step = Step::SurfaceSide; + return CmdResult::NeedPoint; + } + } + self.notice = Some( + "SLICE The selected surface does not define one supported cutting sheet. Select another surface:", + ); + return CmdResult::NeedPoint; + } + match self.picked_plane() { + Some(plane) => self.enter_side(plane), + None => { + self.notice = Some( + "SLICE The selected object does not define a plane. Select a planar curve object:", + ); + CmdResult::NeedPoint + } + } + } + + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let mut wires = Vec::new(); + let plane = match self.step { + Step::ThirdPoint(first, second) => { + wires.push(WireModel::solid_f64( + "slice_triangle".into(), + vec![ + first.to_array(), + second.to_array(), + point.to_array(), + first.to_array(), + ], + WireModel::CYAN, + false, + )); + Self::plane(first, second - first, (second - first).cross(point - first)) + } + Step::AxisSecond(origin) => { + wires.push(WireModel::solid_f64( + "slice_normal".into(), + vec![origin.to_array(), point.to_array()], + WireModel::CYAN, + false, + )); + let normal = point - origin; + let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + Self::plane(origin, x, normal) + } + Step::PlanePoint(kind) => self.plane_at(point, kind), + Step::Side(plane) => Some(plane), + _ => None, + }; + if let Some(plane) = plane { + wires.extend(plane_grid( + plane, + self.preview_center, + self.preview_radius, + )); + } + wires + } +} + +fn planar_body_plane(body: &cadkernel::brep::Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let normal = DVec3::from_array(first.normal()?); + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + faces + .all(|face| { + let Some(profile) = cadkernel::brep::planar_face_profile(body, face) else { + return false; + }; + profile.plane.normal().is_some_and(|other| { + normal.dot(DVec3::from_array(other)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|gap| gap.abs() <= tolerance * 4.0) + }) + }) + .then_some(first) +} + +fn plane_grid(plane: Plane, centre: DVec3, radius: f64) -> Vec { + let normal = DVec3::from_array(plane.normal().unwrap_or([0.0, 0.0, 1.0])); + let projected = centre + - normal + * plane + .distance_to(centre.to_array()) + .unwrap_or_default(); + let x = DVec3::from_array(plane.x_axis); + let y = DVec3::from_array(plane.y_axis); + let color = [0.2, 0.55, 1.0, 1.0]; + (-5..=5) + .flat_map(|index| { + let offset = radius * index as f64 / 5.0; + [ + WireModel::solid_f64( + format!("slice_grid_x_{index}"), + vec![ + (projected + x * offset - y * radius).to_array(), + (projected + x * offset + y * radius).to_array(), + ], + color, + false, + ), + WireModel::solid_f64( + format!("slice_grid_y_{index}"), + vec![ + (projected + y * offset - x * radius).to_array(), + (projected + y * offset + x * radius).to_array(), + ], + color, + false, + ), + ] + }) + .collect() +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SLICE", "SL"] +}); From 1c9eb46b7890ced1b4edfdcb87a48baeb2ba42f9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:35:23 +0300 Subject: [PATCH 06/40] Update slice kernel revision --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a164e9172..3427e7e30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=70aa5178c59d277b14c13dddffa502633d4cfbbc#70aa5178c59d277b14c13dddffa502633d4cfbbc" +source = "git+https://github.com/ramox81/cadkernel.git?rev=f041b46431ac1e6fb38ae61d76bd7c2814b26164#f041b46431ac1e6fb38ae61d76bd7c2814b26164" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 8d0e8bdf5..05c892cca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "70aa5178c59d277b14c13dddffa502633d4cfbbc", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f041b46431ac1e6fb38ae61d76bd7c2814b26164", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 62d0d4198..0ecd39b9a 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "70aa5178c59d277b14c13dddffa502633d4cfbbc", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f041b46431ac1e6fb38ae61d76bd7c2814b26164", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] From 6616111fbfd84739e426f51f627093a1dfb15780 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 8 Sep 2026 10:18:53 +0200 Subject: [PATCH 07/40] Fix Architectural and Engineering inch unit formatting Suppress leading 0 feet notation when feet = 0 in architectural (LUNITS 4) and engineering (LUNITS 3) formats. Values less than one foot should display as inches only (e.g. 9 1/4 inches instead of 0 feet 9 1/4 inches). - For Architectural: 9.25 inches now displays as 9 1/4 inch mark instead of 0 feet 9 1/4 inch mark - For Engineering: 11.94 now displays as 11.9 inch mark instead of 0 feet 11.9 inch mark - Proper feet notation is preserved for values >= 1 foot - Updated tests to reflect correct expected values Fixes issue 1066 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/entities/common.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 1f837daca..cc3ed1cc5 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -142,7 +142,13 @@ pub fn format_length(value: f64) -> String { }; let unit_suffix = if ctx.lunits == 4 { "\"" } else { "" }; match feet { - Some(f) => format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix), + Some(f) => { + if f == 0.0 && whole > 0.0 { + format!("{}{:.0}{}{}", sign, whole, frac_str, unit_suffix) + } else { + format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix) + } + } None => format!("{}{:.0}{}", sign, whole, frac_str), } } @@ -1102,7 +1108,7 @@ mod length_format_tests { #[test] fn architectural_carries_into_feet() { - assert_eq!(with_units(4, 4, 11.99), "0'-11 63/64\""); + assert_eq!(with_units(4, 4, 11.99), "11 63/64\""); assert_eq!(with_units(4, 4, 11.999), "1'-0\""); assert_eq!(with_units(4, 4, 23.999), "2'-0\""); assert_eq!(with_units(4, 4, 66.5), "5'-6 1/2\""); @@ -1111,7 +1117,7 @@ mod length_format_tests { #[test] fn engineering_carries_into_feet() { - assert_eq!(with_units(3, 1, 11.94), "0'-11.9\""); + assert_eq!(with_units(3, 1, 11.94), "11.9\""); assert_eq!(with_units(3, 1, 11.99), "1'-0.0\""); assert_eq!(with_units(3, 1, 23.99), "2'-0.0\""); assert_eq!(with_units(3, 2, 11.999), "1'-0.00\""); From 5b663bfe38ff02adf4b92fd78750a1ed006d6ac1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 12:14:28 +0300 Subject: [PATCH 08/40] Fix issues found while integrating contributor PRs --- locales/nl-NL/opencadstudio.ftl | 12 ++++----- src/entities/common.rs | 19 ++++++++----- src/io/pdf_export.rs | 47 ++++++++++++++++++++------------- src/ui/color_select.rs | 15 +++-------- src/ui/ribbon/widgets.rs | 7 +++-- 5 files changed, 52 insertions(+), 48 deletions(-) diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 08dc9dfc3..90970dca3 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2771,11 +2771,9 @@ annotate = .mleader = MLeader .mtext-specify-insertion-point = MTEXT Plaatsingspunt specificeren: .markup = Markering - .multileader = Multiverwijs Lijn + .multileader = Multiverwijslijn .multileader-draw-order = Tekenvolgorde multiverwijslijn: - .multiline-text = - Multiline - Tekst + .multiline-text = Multilijntekst .ordinate = Ordinaat .out = Uit .qdim-select-geometry-to-dimension-enter-when-d = QDIM Selecteer de te bematen geometrie (Enter om te voltooien): @@ -3879,9 +3877,9 @@ properties = .mode = Modus .monochrome = Monochroom .more-colors = Meer kleuren... - .multileader = Multiverwijs Lijn - .multiline = Multilijn Tekst - .multileader-style = Multiverwijs Lijnstijl + .multileader = Multiverwijslijn + .multiline = Multilijntekst + .multileader-style = Multiverwijslijnstijl .multiple-lines = Meerdere regels .n-closed = N gesloten .n-density = N-dichtheid diff --git a/src/entities/common.rs b/src/entities/common.rs index cc3ed1cc5..1d1fbee41 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -114,7 +114,11 @@ pub fn format_length(value: f64) -> String { let per_foot = 12.0 * scale; let feet = (total / per_foot).trunc(); let rem = (total - feet * per_foot) / scale; - format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem) + if feet == 0.0 { + format!("{}{:.*}\"", sign, prec, rem) + } else { + format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem) + } } 4 | 5 => { // Architectural and fractional formats use 1/64-inch resolution. @@ -142,13 +146,10 @@ pub fn format_length(value: f64) -> String { }; let unit_suffix = if ctx.lunits == 4 { "\"" } else { "" }; match feet { - Some(f) => { - if f == 0.0 && whole > 0.0 { - format!("{}{:.0}{}{}", sign, whole, frac_str, unit_suffix) - } else { - format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix) - } + Some(f) if f == 0.0 => { + format!("{}{:.0}{}{}", sign, whole, frac_str, unit_suffix) } + Some(f) => format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix), None => format!("{}{:.0}{}", sign, whole, frac_str), } } @@ -1109,6 +1110,8 @@ mod length_format_tests { #[test] fn architectural_carries_into_feet() { assert_eq!(with_units(4, 4, 11.99), "11 63/64\""); + assert_eq!(with_units(4, 4, 0.5), "0 1/2\""); + assert_eq!(with_units(4, 4, -9.25), "-9 1/4\""); assert_eq!(with_units(4, 4, 11.999), "1'-0\""); assert_eq!(with_units(4, 4, 23.999), "2'-0\""); assert_eq!(with_units(4, 4, 66.5), "5'-6 1/2\""); @@ -1118,6 +1121,8 @@ mod length_format_tests { #[test] fn engineering_carries_into_feet() { assert_eq!(with_units(3, 1, 11.94), "11.9\""); + assert_eq!(with_units(3, 1, 0.5), "0.5\""); + assert_eq!(with_units(3, 1, -9.25), "-9.3\""); assert_eq!(with_units(3, 1, 11.99), "1'-0.0\""); assert_eq!(with_units(3, 1, 23.99), "2'-0.0\""); assert_eq!(with_units(3, 2, 11.999), "1'-0.00\""); diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 88911fd21..10b72da48 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -20,8 +20,6 @@ use printpdf::{ PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem, WindingOrder, }; -#[cfg(not(target_arch = "wasm32"))] -use std::io::Write; use std::path::Path; #[derive(Clone, Debug)] @@ -192,24 +190,19 @@ pub fn export_pdf_pages( write_pdf_atomically(path, &bytes) } -/// Atomically write PDF bytes to a file. Writes to a temporary file first, -/// then renames it to the target path to avoid crashes when overwriting -/// a file that is locked by another process (e.g., PDF reader). +/// Write a complete PDF beside the destination, then replace it atomically. #[cfg(not(target_arch = "wasm32"))] fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { - let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); - let file_name = path.file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| "Invalid file path".to_string())?; - let temp_path = dir.join(format!(".{}.tmp", file_name)); - - let mut file = std::fs::File::create(&temp_path) - .map_err(|e| format!("Failed to create temporary PDF file: {}", e))?; - file.write_all(bytes) - .map_err(|e| format!("Failed to write PDF data: {}", e))?; - - std::fs::rename(&temp_path, path) - .map_err(|e| format!("Failed to write PDF file: {} (the file may be open in another application)", e)) + let temp_path = super::save_temp_path(path); + if let Err(error) = std::fs::write(&temp_path, bytes) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("Failed to write PDF data: {error}")); + } + if let Err(error) = super::replace_save_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("Failed to replace PDF file: {error}")); + } + Ok(()) } /// Show a parented PDF save-file dialog and return the chosen path. @@ -1523,6 +1516,24 @@ fn emit_text( mod tests { use super::*; + #[test] + fn atomic_write_replaces_an_existing_pdf() { + let path = std::env::temp_dir().join(format!( + "ocs-pdf-replace-{}-{}.pdf", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"old").unwrap(); + + write_pdf_atomically(&path, b"new").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + std::fs::remove_file(path).unwrap(); + } + #[test] fn clip_and_scale_emit_pdf_bytes() { let w = PlotWire { diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index 82a41c6f8..bd05c1db7 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -8,14 +8,9 @@ use crate::ui::properties::acad_color_display; use crate::ui::ROW_H; use acadrust::types::Color as AcadColor; use iced::widget::{button, column, container, row, text}; -use iced::{Background, Border, Color, Element, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Length, Theme}; use crate::t; -/// Font size for color selector buttons (matches combo_box styling). -const FONT_SZ: f32 = ROW_H * 0.42; -/// Vertical padding for color selector buttons (matches combo_box styling). -const SELECTOR_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; - /// Which "logical" entries the colour list offers besides the standard ACI /// colours. #[derive(Clone, Copy, Default)] @@ -228,12 +223,8 @@ fn color_selector_with_indicator<'a>( .align_y(iced::Center), ) .on_press(on_toggle) - .padding(Padding { - top: SELECTOR_PAD_V, - bottom: SELECTOR_PAD_V, - left: 6.0, - right: 6.0, - }) + .padding([3, 6]) + .height(ROW_H) .width(Length::Fill); if !open { diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 7d9276c40..7a77c6d9d 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -128,14 +128,13 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 { .0; let max_label_height = line_height * LARGE_LABEL_LINES + 0.5; let fits = |width: f32| { - ribbon_label_bounds( + let bounds = ribbon_label_bounds( renderer, label, width, advanced_text::Wrapping::Word, - ) - .height - <= max_label_height + ); + bounds.width <= width + 0.5 && bounds.height <= max_label_height }; if fits(base_inner) { From 8bb6703c7064397934e8969198a4941b6bff3d07 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:36:49 +0300 Subject: [PATCH 09/40] Complete FILLETEDGE workflow and history --- Cargo.lock | 4 +- Cargo.toml | 5 +- locales/en-US/opencadstudio.ftl | 11 + locales/tr-TR/opencadstudio.ftl | 11 + src/app/command_driver.rs | 5 +- src/app/commands/dim.rs | 65 +++- src/app/model_ops.rs | 113 +++++- src/app/update/dynamic.rs | 8 + src/app/update/viewport.rs | 8 + src/command.rs | 4 +- src/locale_catalog.rs | 11 + src/modules/model/edge_cmd.rs | 583 ++++++++++++++++++++++++++++--- src/modules/model/mod.rs | 2 +- src/scene/entity.rs | 4 +- src/scene/model/solid_history.rs | 73 +++- src/scene/modify.rs | 65 +++- 16 files changed, 901 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 326bb7474..e4aceb44e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b56571a190e7a17c8f12d36390d2938b0fb72f7#5b56571a190e7a17c8f12d36390d2938b0fb72f7" +source = "git+https://github.com/ramox81/cadcodec.git?rev=1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d#1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192" +source = "git+https://github.com/ramox81/cadkernel.git?rev=a1286c8fe2223973293c1010c7f8329e4ba0daf5#a1286c8fe2223973293c1010c7f8329e4ba0daf5" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 21a3ba4a8..82788538f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,10 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" } + +[patch."https://github.com/HakanSeven12/cadkernel.git"] +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "a1286c8fe2223973293c1010c7f8329e4ba0daf5" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6c97312fe..2edae3fdb 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3187,6 +3187,17 @@ model = .cylinder-specify-axis-endpoint = CYLINDER Specify axis endpoint: .elliptical = Elliptical .select-a-solid-edge = Select a solid edge: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = Specify fillet radius <__ocs_fmt_0__>: .specify-chamfer-distance = Specify chamfer distance <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID Height = __ocs_fmt_0__, Width = __ocs_fmt_1__, Justification = __ocs_fmt_2__ diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 69705fee8..bbc60a6e3 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3146,6 +3146,17 @@ model = .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptik .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Bir kenar seçin veya [Zincir/Döngü/Yarıçap]: + .filletedge-select-edge-chain-or-edge-radius = Bir kenar zinciri seçin veya [Kenar/Yarıçap]: + .filletedge-select-loop-edge-or-edge-chain-radius = Döngünün kenarını seçin veya [Kenar/Zincir/Yarıçap]: + .filletedge-enter-option-accept-next = Bir seçenek girin [Kabul/Sonraki] : + .filletedge-accept-or-radius = Fillet'i kabul etmek için Enter'a basın veya [Yarıçap]: + .filletedge-enter-radius-or-expression = Fillet yarıçapını girin veya [İfade] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Yarıçapı belirtin veya [İfade] <__ocs_fmt_0__>: + .filletedge-enter-expression = İfadeyi girin: + .filletedge-stale-edge = Seçilen kenar artık katıya ait değil. + .filletedge-history-create-failed = Katı geçmişi oluşturulamadı. + .filletedge-history-append-failed = Fillet geçmişi kaydedilemedi. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b74e15fd..b0db25725 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -733,6 +733,7 @@ impl OpenCADStudio { &result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..) + | CmdResult::SolidEdgeBlend { .. } | CmdResult::SolidSubtract { .. } ); let task = self.apply_cmd_result_inner(result); @@ -4103,11 +4104,11 @@ impl OpenCADStudio { CmdResult::SolidEdgeBlend { handle, - pick, + edges, value, fillet, } => { - let task = self.solid_edge_blend(handle, pick, value, fillet); + let task = self.solid_edge_blend(handle, &edges, value, fillet); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index b5c2a0eaf..c083f6f60 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -17,6 +17,33 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { } } +fn solid_edge_sources( + app: &mut OpenCADStudio, + tab: usize, +) -> Vec<(acadrust::Handle, cadkernel::brep::Body)> { + let handles = app.tabs[tab] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)) + .then_some(entity.common().handle) + }) + .collect::>(); + app.tabs[tab].scene.restore_solid_models(&handles); + handles + .into_iter() + .filter_map(|handle| { + app.tabs[tab] + .scene + .solid_models + .get(&handle) + .cloned() + .map(|body| (handle, body)) + }) + .collect() +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -960,16 +987,30 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "SOLIDFILLET" => { + "FILLETEDGE" | "SOLIDFILLET" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Fillet, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } "FILLET" if selected_solid(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Fillet, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } @@ -1109,14 +1150,28 @@ impl OpenCADStudio { "SOLIDCHAMFER" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Chamfer, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } "CHAMFER" if selected_solid(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + let target = selected_solid(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Chamfer, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a262c0ee3..16e87d6f1 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -7,7 +7,7 @@ use acadrust::{ objects::SolidHistoryOperation, EntityType, Handle, }; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, EdgeKey}; use iced::Task; use std::collections::HashMap; @@ -596,10 +596,92 @@ impl super::OpenCADStudio { true } + fn replace_solid_body_with_fillet( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + radius: f64, + ) -> bool { + let i = self.active_tab; + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&result) else { + self.command_line + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return false; + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + return false; + }; + let ordered = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the solid.").as_ref()); + return false; + }; + + self.push_undo_snapshot(i, "FILLETEDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) + { + self.command_line + .push_error(crate::t!("The solid history could not be created.").as_ref()); + return false; + } + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::fillet_op(history_edges, radius), + ) { + self.command_line + .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); + return false; + } + let Some(EntityType::Solid3D(mut entity)) = + self.tabs[i].scene.document.get_entity(handle).cloned() + else { + return false; + }; + entity.wires = solid_model::edge_wires(&result); + entity.silhouettes.clear(); + entity.set_sat_document(&document); + if !self.tabs[i] + .scene + .update_entity(EntityType::Solid3D(entity)) + { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return false; + } + self.tabs[i] + .scene + .register_prepared_solid_model(handle, result, display); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + pub(super) fn solid_edge_blend( &mut self, handle: Handle, - pick: glam::DVec3, + edges: &[EdgeKey], value: f64, fillet: bool, ) -> Task { @@ -621,15 +703,19 @@ impl super::OpenCADStudio { .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); return Task::none(); }; - let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else { - self.command_line - .push_error(crate::t!("Select a solid edge.").as_ref()); - return Task::none(); - }; let result = if fillet { - cadkernel::brep::fillet(&body, edge, value) + match cadkernel::brep::fillet_edges(&body, edges, value) { + Ok(result) => Some(result), + Err(error) => { + self.command_line + .push_error(&format!("FILLETEDGE: {error}")); + return Task::none(); + } + } } else { - cadkernel::brep::chamfer(&body, edge, value) + edges + .first() + .and_then(|edge| cadkernel::brep::chamfer(&body, *edge, value)) }; let Some(result) = result else { self.command_line.push_error( @@ -638,8 +724,13 @@ impl super::OpenCADStudio { ); return Task::none(); }; - let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" }; - if self.replace_solid_body(handle, result, label) { + let label = if fillet { "FILLETEDGE" } else { "SOLIDCHAMFER" }; + let updated = if fillet { + self.replace_solid_body_with_fillet(handle, &body, result, edges, value) + } else { + self.replace_solid_body(handle, result, label) + }; + if updated { self.command_line .push_output(crate::tf!("{label}: solid updated.").as_ref()); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index feaf6b016..1e1888055 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -740,6 +740,14 @@ impl OpenCADStudio { .as_mut() .map(|c| c.on_preview_wires(cur)) .unwrap_or_default(); + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c39916fe4..12c339a01 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2468,6 +2468,14 @@ impl OpenCADStudio { previews.push(guide); } } + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } else { self.tabs[i].snap_result = None; diff --git a/src/command.rs b/src/command.rs index 25ccfb027..f91b124b8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1547,10 +1547,10 @@ pub enum CmdResult { options: LoftOptions, color: [f32; 4], }, - /// Round or bevel the straight edge nearest `pick` on a solid. + /// Round or bevel one or more resolved B-rep edges on a solid. SolidEdgeBlend { handle: Handle, - pick: DVec3, + edges: Vec, value: f64, fillet: bool, }, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index eda54347e..72a2814df 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3871,8 +3871,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "CYLINDER Specify axis endpoint:" => Some(("model", "cylinder-specify-axis-endpoint")), "Elliptical" => Some(("model", "elliptical")), "Select a solid edge:" => Some(("model", "select-a-solid-edge")), + "Select an edge or [Chain/Loop/Radius]:" => Some(("model", "filletedge-select-edge-or-chain-loop-radius")), + "Select an edge chain or [Edge/Radius]:" => Some(("model", "filletedge-select-edge-chain-or-edge-radius")), + "Select edge of loop or [Edge/Chain/Radius]:" => Some(("model", "filletedge-select-loop-edge-or-edge-chain-radius")), + "Enter an option [Accept/Next] :" => Some(("model", "filletedge-enter-option-accept-next")), + "Press Enter to accept the fillet or [Radius]:" => Some(("model", "filletedge-accept-or-radius")), + "Enter fillet radius or [Expression] <{:.4}>:" => Some(("model", "filletedge-enter-radius-or-expression")), + "Specify Radius or [Expression] <{:.4}>:" => Some(("model", "filletedge-specify-radius-or-expression")), + "Enter expression:" => Some(("model", "filletedge-enter-expression")), "Specify fillet radius <{:.3}>:" => Some(("model", "specify-fillet-radius")), "Specify chamfer distance <{:.3}>:" => Some(("model", "specify-chamfer-distance")), + "A selected edge no longer belongs to the solid." => Some(("model", "filletedge-stale-edge")), + "The solid history could not be created." => Some(("model", "filletedge-history-create-failed")), + "The fillet history could not be recorded." => Some(("model", "filletedge-history-append-failed")), "POLYSOLID Height = {:.4}, Width = {:.4}, Justification = {}\n{}" => Some(("model", "polysolid-height-width-justification")), "Specify start point or [Object/Height/Width/Justify] :" => Some(("model", "specify-start-point-or-object-height-width-justify-object")), "Specify height <{:.4}>:" => Some(("model", "specify-height")), diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index 65ee2d315..a88e2fc88 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -1,42 +1,232 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + use acadrust::Handle; +use cadkernel::brep::{Body, EdgeKey}; use glam::DVec3; use crate::command::{ - CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, + CadCommand, CmdOption, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, }; +use crate::scene::model::wire_model::WireModel; -#[derive(Clone, Copy)] +static FILLET_RADIUS_BITS: AtomicU64 = AtomicU64::new(1.0f64.to_bits()); + +fn fillet_radius() -> f64 { + f64::from_bits(FILLET_RADIUS_BITS.load(Ordering::Relaxed)) +} + +fn set_fillet_radius(value: f64) { + FILLET_RADIUS_BITS.store(value.to_bits(), Ordering::Relaxed); +} + +#[derive(Clone, Copy, PartialEq, Eq)] pub enum EdgeOperation { Fillet, Chamfer, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeSelectionMode { + Edge, + Chain, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeStep { + Selecting, + PickingLoop, + LoopConfirm, + PreviewConfirm, + Radius, + Expression, + ChamferValue, +} + pub struct SolidEdgeCommand { operation: EdgeOperation, target: Option, - picked: Option<(Handle, DVec3)>, + bodies: Vec<(Handle, Body)>, + handle: Option, + selected_edges: Vec, + selection_batches: Vec<(Vec, DVec3)>, + selection_mode: EdgeSelectionMode, + step: EdgeStep, + radius_return: EdgeStep, + loop_candidates: Vec>, + loop_index: usize, + last_pick: Option, default_value: f64, + preview_color: [f32; 4], + preview_wires: Vec, + preview_hidden: Vec, } impl SolidEdgeCommand { - pub fn new(operation: EdgeOperation, target: Option) -> Self { + pub fn new( + operation: EdgeOperation, + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + ) -> Self { Self { operation, target, - picked: None, - default_value: 1.0, + bodies, + handle: None, + selected_edges: Vec::new(), + selection_batches: Vec::new(), + selection_mode: EdgeSelectionMode::Edge, + step: EdgeStep::Selecting, + radius_return: EdgeStep::Selecting, + loop_candidates: Vec::new(), + loop_index: 0, + last_pick: None, + default_value: if operation == EdgeOperation::Fillet { + fillet_radius() + } else { + 1.0 + }, + preview_color, + preview_wires: Vec::new(), + preview_hidden: Vec::new(), } } - fn finish(&self, value: f64) -> CmdResult { - let Some((handle, pick)) = self.picked else { + fn body(&self, handle: Handle) -> Option<&Body> { + self.bodies + .iter() + .find_map(|(candidate, body)| (*candidate == handle).then_some(body)) + } + + fn active_body(&self) -> Option<(Handle, &Body)> { + let handle = self.handle?; + self.body(handle).map(|body| (handle, body)) + } + + fn pick_allowed(&self, handle: Handle) -> bool { + !handle.is_null() + && self.target.is_none_or(|target| target == handle) + && self.handle.is_none_or(|selected| selected == handle) + && self.body(handle).is_some() + } + + fn add_batch(&mut self, edges: Vec, anchor: DVec3) -> bool { + let batch = edges + .into_iter() + .filter(|edge| !self.selected_edges.contains(edge)) + .collect::>(); + if batch.is_empty() { + return false; + } + self.selected_edges.extend(batch.iter().copied()); + self.selection_batches.push((batch, anchor)); + true + } + + fn current_loop(&self) -> Option<&[EdgeKey]> { + self.loop_candidates + .get(self.loop_index) + .map(Vec::as_slice) + } + + fn preview_edges(&self) -> Vec { + let mut edges = self.selected_edges.clone(); + if self.step == EdgeStep::LoopConfirm { + if let Some(loop_edges) = self.current_loop() { + for edge in loop_edges { + if !edges.contains(edge) { + edges.push(*edge); + } + } + } + } + edges + } + + fn preview_active(&self) -> bool { + self.operation == EdgeOperation::Fillet + && matches!( + self.step, + EdgeStep::LoopConfirm + | EdgeStep::PreviewConfirm + | EdgeStep::Radius + | EdgeStep::Expression + ) + } + + fn preview_for_value(&self, value: f64) -> Option<(Handle, Vec)> { + if !self.preview_active() { + return None; + } + let edges = self.preview_edges(); + if edges.is_empty() { + return None; + } + let (handle, body) = self.active_body()?; + let result = cadkernel::brep::fillet_edges(body, &edges, value).ok()?; + let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + for wire in &mut wires { + wire.color = self.preview_color; + wire.name = format!("{}-FILLETEDGE-PREVIEW", handle.value()); + } + (!wires.is_empty()).then_some((handle, wires)) + } + + fn rebuild_preview(&mut self) { + self.preview_wires.clear(); + self.preview_hidden.clear(); + if let Some((handle, wires)) = self.preview_for_value(self.default_value) { + self.preview_wires = wires; + self.preview_hidden.push(handle); + } + } + + fn begin_radius(&mut self, return_to: EdgeStep) -> CmdResult { + self.radius_return = return_to; + self.step = EdgeStep::Radius; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_radius(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + self.default_value = value; + if self.operation == EdgeOperation::Fillet { + set_fillet_radius(value); + } + self.step = self.radius_return; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn accept_loop(&mut self) -> CmdResult { + if let (Some(edges), Some(anchor)) = ( + self.current_loop().map(|edges| edges.to_vec()), + self.last_pick, + ) { + self.add_batch(edges, anchor); + } + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn finish(&self) -> CmdResult { + let Some(handle) = self.handle else { return CmdResult::Cancel; }; + if self.selected_edges.is_empty() { + return CmdResult::Cancel; + } CmdResult::SolidEdgeBlend { handle, - pick, - value, - fillet: matches!(self.operation, EdgeOperation::Fillet), + edges: self.selected_edges.clone(), + value: self.default_value, + fillet: self.operation == EdgeOperation::Fillet, } } } @@ -44,27 +234,83 @@ impl SolidEdgeCommand { impl CadCommand for SolidEdgeCommand { fn name(&self) -> &'static str { match self.operation { - EdgeOperation::Fillet => "SOLIDFILLET", + EdgeOperation::Fillet => "FILLETEDGE", EdgeOperation::Chamfer => "SOLIDCHAMFER", } } fn prompt(&self) -> String { - if self.picked.is_none() { - return crate::t!("Select a solid edge:").into_owned(); + match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => { + crate::t!("Select a solid edge:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::ChamferValue, _) => crate::tf!( + "Specify chamfer distance <{:.3}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Selecting, EdgeSelectionMode::Edge) => { + crate::t!("Select an edge or [Chain/Loop/Radius]:").into_owned() + } + (_, EdgeStep::Selecting, EdgeSelectionMode::Chain) => { + crate::t!("Select an edge chain or [Edge/Radius]:").into_owned() + } + (_, EdgeStep::PickingLoop, _) => { + crate::t!("Select edge of loop or [Edge/Chain/Radius]:").into_owned() + } + (_, EdgeStep::LoopConfirm, _) => { + crate::t!("Enter an option [Accept/Next] :").into_owned() + } + (_, EdgeStep::PreviewConfirm, _) => { + crate::t!("Press Enter to accept the fillet or [Radius]:").into_owned() + } + (_, EdgeStep::Radius, _) if self.radius_return == EdgeStep::PreviewConfirm => crate::tf!( + "Specify Radius or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Radius, _) => crate::tf!( + "Enter fillet radius or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Expression, _) => crate::t!("Enter expression:").into_owned(), + _ => String::new(), } - match self.operation { - EdgeOperation::Fillet => { - crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned() + } + + fn options(&self) -> Vec { + match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Edge) => vec![ + CmdOption::new("Chain", "C"), + CmdOption::new("Loop", "L"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Chain) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::PickingLoop, _) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Chain", "C"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::LoopConfirm, _) => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + (EdgeOperation::Fillet, EdgeStep::PreviewConfirm, _) => { + vec![CmdOption::new("Radius", "R")] } - EdgeOperation::Chamfer => { - crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned() + (EdgeOperation::Fillet, EdgeStep::Radius, _) => { + vec![CmdOption::new("Expression", "E")] } + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.picked.is_none() + matches!(self.step, EdgeStep::Selecting | EdgeStep::PickingLoop) } fn entity_pick_includes_fills(&self) -> bool { @@ -80,52 +326,199 @@ impl CadCommand for SolidEdgeCommand { } fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { - if handle.is_null() || self.target.is_some_and(|target| target != handle) { + if !self.pick_allowed(handle) { return CmdResult::NeedPoint; } - self.picked = Some((handle, point)); + let Some(seed) = self + .body(handle) + .and_then(|body| crate::scene::model::solid_model::nearest_edge(body, point.to_array())) + else { + return CmdResult::NeedPoint; + }; + self.handle = Some(handle); + self.last_pick = Some(point); + + if self.operation == EdgeOperation::Chamfer { + self.add_batch(vec![seed], point); + self.step = EdgeStep::ChamferValue; + return CmdResult::NeedPoint; + } + + if self.step == EdgeStep::PickingLoop { + self.loop_candidates = edge_loops(self.body(handle).expect("pick body exists"), seed); + self.loop_index = 0; + self.step = if self.loop_candidates.is_empty() { + EdgeStep::Selecting + } else { + EdgeStep::LoopConfirm + }; + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + + let edges = match self.selection_mode { + EdgeSelectionMode::Edge => vec![seed], + EdgeSelectionMode::Chain => edge_chain(self.body(handle).expect("pick body exists"), seed), + }; + self.add_batch(edges, point); CmdResult::NeedPoint } fn on_point(&mut self, point: DVec3) -> CmdResult { - let Some((_, pick)) = self.picked else { + if !matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) { + return CmdResult::NeedPoint; + } + let Some(anchor) = self.last_pick else { return CmdResult::NeedPoint; }; - let value = point.distance(pick); - if value > 0.0 && value.is_finite() { - self.finish(value) + let value = point.distance(anchor); + if value <= 0.0 || !value.is_finite() { + return CmdResult::NeedPoint; + } + if self.step == EdgeStep::ChamferValue { + self.default_value = value; + self.finish() } else { - CmdResult::NeedPoint + self.accept_radius(value).unwrap_or(CmdResult::NeedPoint) } } fn on_text_input(&mut self, text: &str) -> Option { - if self.picked.is_none() { - return None; + let keyword = text.trim().to_ascii_uppercase(); + if self.operation == EdgeOperation::Chamfer { + if self.step != EdgeStep::ChamferValue { + return None; + } + let value = keyword.parse::().ok()?; + if value <= 0.0 || !value.is_finite() { + return None; + } + self.default_value = value; + return Some(self.finish()); + } + + match self.step { + EdgeStep::Selecting => match keyword.as_str() { + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::Selecting)), + "C" | "CHAIN" => { + self.selection_mode = EdgeSelectionMode::Chain; + Some(CmdResult::NeedPoint) + } + "E" | "EDGE" if self.selection_mode == EdgeSelectionMode::Chain => { + self.selection_mode = EdgeSelectionMode::Edge; + Some(CmdResult::NeedPoint) + } + "L" | "LOOP" => { + self.step = EdgeStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.selection_mode = EdgeSelectionMode::Edge; + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "C" | "CHAIN" => { + self.selection_mode = EdgeSelectionMode::Chain; + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PickingLoop)), + _ => None, + }, + EdgeStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PreviewConfirm => match keyword.as_str() { + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PreviewConfirm)), + _ => None, + }, + EdgeStep::Radius => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.step = EdgeStep::Expression; + return Some(CmdResult::NeedPoint); + } + let value = keyword.parse::().ok()?; + self.accept_radius(value) + } + EdgeStep::Expression => { + let value = keyword.parse::().ok()?; + self.accept_radius(value) + } + EdgeStep::ChamferValue => None, } - let value = text.trim().parse::().ok()?; - (value > 0.0 && value.is_finite()).then(|| self.finish(value)) } fn on_enter(&mut self) -> CmdResult { - if self.picked.is_some() { - self.finish(self.default_value) - } else { - CmdResult::Cancel + match (self.operation, self.step) { + (EdgeOperation::Chamfer, EdgeStep::ChamferValue) => self.finish(), + (EdgeOperation::Chamfer, _) => CmdResult::Cancel, + (_, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { + self.step = EdgeStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + (_, EdgeStep::LoopConfirm) => self.accept_loop(), + (_, EdgeStep::PreviewConfirm) => self.finish(), + (_, EdgeStep::Radius) => self + .accept_radius(self.default_value) + .unwrap_or(CmdResult::NeedPoint), + (_, EdgeStep::Expression) => CmdResult::NeedPoint, + _ => CmdResult::Cancel, } } + fn on_undo_step(&mut self) -> Option { + if self.operation != EdgeOperation::Fillet { + return None; + } + if self.step == EdgeStep::LoopConfirm { + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + return Some(CmdResult::NeedPoint); + } + let (batch, _) = self.selection_batches.pop()?; + for edge in batch { + if let Some(index) = self.selected_edges.iter().position(|candidate| *candidate == edge) { + self.selected_edges.remove(index); + } + } + if self.selected_edges.is_empty() { + self.handle = None; + } + self.last_pick = self + .selection_batches + .last() + .map(|(_, anchor)| *anchor); + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + fn wants_text_input(&self) -> bool { - self.picked.is_some() + matches!( + self.step, + EdgeStep::Radius | EdgeStep::Expression | EdgeStep::ChamferValue + ) } fn dyn_commit_as_text(&self) -> bool { - self.picked.is_some() + matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) } fn dyn_spec(&self) -> Option { - let (_, pick) = self.picked?; - Some(DynSpec { + let pick = self.last_pick?; + matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue).then(|| DynSpec { anchor: DynAnchor::Point(pick), fields: vec![DynFieldSpec::new(match self.operation { EdgeOperation::Fillet => DynRole::Radius, @@ -137,10 +530,118 @@ impl CadCommand for SolidEdgeCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - self.picked.map(|(_, pick)| cursor.distance(pick)) + matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) + .then(|| self.last_pick.map(|pick| cursor.distance(pick))) + .flatten() + } + + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + if self.step == EdgeStep::Radius { + if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) { + if value > 0.0 && value.is_finite() { + if let Some((handle, wires)) = self.preview_for_value(value) { + self.preview_wires = wires; + self.preview_hidden.clear(); + self.preview_hidden.push(handle); + } else { + self.preview_wires.clear(); + self.preview_hidden.clear(); + } + } + } + } + self.preview_wires.clone() + } + + fn preview_hidden_handles(&self) -> &[Handle] { + &self.preview_hidden } } +fn edge_loops(body: &Body, seed: EdgeKey) -> Vec> { + let Some(edge) = body.edges.get(seed) else { + return Vec::new(); + }; + let mut candidates = Vec::new(); + for coedge_key in &edge.coedges { + let Some(coedge) = body.coedges.get(*coedge_key) else { + continue; + }; + let Some(edge_loop) = body.loops.get(coedge.owner) else { + continue; + }; + let edges = edge_loop + .coedges + .iter() + .filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge)) + .collect::>(); + if !edges.is_empty() && !candidates.contains(&edges) { + candidates.push(edges); + } + } + candidates +} + +fn edge_chain(body: &Body, seed: EdgeKey) -> Vec { + let mut chain = vec![seed]; + let mut next = 0; + while next < chain.len() { + let current_key = chain[next]; + next += 1; + let Some(current) = body.edges.get(current_key) else { + continue; + }; + for vertex in [current.start, current.end] { + let Some(current_tangent) = edge_tangent_from_vertex(body, current_key, vertex) else { + continue; + }; + for candidate_key in body.edge_keys() { + if chain.contains(&candidate_key) { + continue; + } + let Some(candidate) = body.edges.get(candidate_key) else { + continue; + }; + if candidate.start != vertex && candidate.end != vertex { + continue; + } + let Some(candidate_tangent) = + edge_tangent_from_vertex(body, candidate_key, vertex) + else { + continue; + }; + if current_tangent.dot(candidate_tangent) < -0.999 { + chain.push(candidate_key); + } + } + } + } + chain +} + +fn edge_tangent_from_vertex( + body: &Body, + edge_key: EdgeKey, + vertex: cadkernel::brep::VertexKey, +) -> Option { + let edge = body.edges.get(edge_key)?; + let curve = body.curves.get(edge.curve)?; + let span = edge.end_parameter - edge.start_parameter; + if !span.is_finite() || span.abs() <= f64::EPSILON { + return None; + } + let step = span * 1.0e-6; + let (at, inside) = if edge.start == vertex { + (edge.start_parameter, edge.start_parameter + step) + } else if edge.end == vertex { + (edge.end_parameter, edge.end_parameter - step) + } else { + return None; + }; + let tangent = DVec3::from(curve.point_at(inside)) - DVec3::from(curve.point_at(at)); + (tangent.length_squared() > 0.0 && tangent.is_finite()).then(|| tangent.normalize()) +} + inventory::submit!(crate::command::CommandRegistration { - names: &["SOLIDFILLET", "SOLIDCHAMFER"] + names: &["FILLETEDGE", "SOLIDFILLET", "SOLIDCHAMFER"] }); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..72699a0b7 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -88,7 +88,7 @@ impl CadModule for ModelModule { RibbonGroup { title: "Edges", tools: vec![ - RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), + RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)), RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), ], }, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a1dbbf3af..ca4609047 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -596,8 +596,8 @@ impl Scene { .filter_map(|&handle| { let from_history = self .document - .solid_history_operation(handle) - .and_then(|operation| cadkernel::acis::rebuild_body(operation).ok()); + .solid_history_operations(handle) + .and_then(|operations| cadkernel::acis::rebuild_history(&operations).ok()); let body = from_history.or_else(|| match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index c52d28732..0e86cd324 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,12 +1,12 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, + SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; use acadrust::EntityType; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, Surface}; use crate::command::EntityTransform; use crate::entities::traits::EntityTypeOps; @@ -32,6 +32,7 @@ pub const GRIP_SWEEP_PATH_FIRST: usize = 30_000; pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000; pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000; pub const GRIP_REVOLVE_AXIS: usize = 10_013; +pub const GRIP_FILLET_RADIUS: usize = 10_014; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -3086,6 +3087,51 @@ fn grip( } } +pub fn fillet_radius_grip(body: &Body, radius: f64) -> Option { + if !radius.is_finite() || radius <= 0.0 { + return None; + } + for (face_key, face) in body.faces.iter() { + let Some(Surface::Cylinder(cylinder)) = body.surfaces.get(face.surface) else { + continue; + }; + if (cylinder.radius.abs() - radius).abs() > radius.max(1.0) * 1.0e-7 { + continue; + } + let axis = glam::DVec3::from_array(cylinder.base.normal()?).try_normalize()?; + let origin = glam::DVec3::from_array(cylinder.base.origin); + let points = body + .face_coedges(face_key) + .into_iter() + .filter_map(|coedge| body.coedges.get(coedge)) + .filter_map(|coedge| body.edges.get(coedge.edge)) + .flat_map(|edge| [edge.start, edge.end]) + .filter_map(|vertex| body.vertices.get(vertex)) + .map(|vertex| glam::DVec3::from_array(vertex.point)) + .collect::>(); + let minimum = points + .iter() + .map(|point| (*point - origin).dot(axis)) + .reduce(f64::min)?; + let maximum = points + .iter() + .map(|point| (*point - origin).dot(axis)) + .reduce(f64::max)?; + let center = origin + axis * ((minimum + maximum) * 0.5); + let radial = points + .iter() + .map(|point| *point - center - axis * (*point - center).dot(axis)) + .find_map(|radial| radial.try_normalize())?; + return Some(grip( + GRIP_FILLET_RADIUS, + center + radial * radius, + GripShape::Square, + Some(radial), + )); + } + None +} + pub fn primitive_grips( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -3093,6 +3139,18 @@ pub fn primitive_grips( let Some(operation) = document.solid_history_operation(handle) else { return Vec::new(); }; + if let SolidHistoryOperation::Fillet(value) = operation { + let Some(radius) = value.radii.first().copied() else { + return Vec::new(); + }; + let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { + return Vec::new(); + }; + return crate::scene::convert::solid3d_tess::kernel_body(solid) + .and_then(|body| fillet_radius_grip(&body, radius)) + .into_iter() + .collect(); + } let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3713,3 +3771,14 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } + +pub fn fillet_op(edges: Vec, radius: f64) -> SolidHistoryOperation { + SolidHistoryOperation::Fillet(SolidHistoryFillet { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + edges, + radii: vec![radius], + ..SolidHistoryFillet::default() + }) +} diff --git a/src/scene/modify.rs b/src/scene/modify.rs index f84605425..47dc462ca 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -802,6 +802,29 @@ impl Scene { true } + pub fn append_solid_history( + &mut self, + handle: Handle, + operation: acadrust::objects::SolidHistoryOperation, + ) -> bool { + let previous = self + .document + .solid_history_graph(handle) + .map(|graph| graph.nodes) + .unwrap_or_default(); + self.record_solid_history_before(handle); + let Some(graph) = self.document.append_solid_history(handle, operation) else { + return false; + }; + for node in graph.nodes { + if !previous.contains(&node) { + self.record_undo_object_before(node, None); + } + } + self.sync_solid_reference_point(handle); + true + } + pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document @@ -855,8 +878,14 @@ impl Scene { (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { cadkernel::acis::rebuild_extrusion_with_mode(value, true).ok() } - (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) - | (EntityType::Solid3D(_), _) => cadkernel::acis::rebuild_body(operation).ok(), + (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) => { + cadkernel::acis::rebuild_body(operation).ok() + } + (EntityType::Solid3D(_), _) => { + let mut operations = self.document.solid_history_operations(handle)?; + *operations.last_mut()? = operation.clone(); + cadkernel::acis::rebuild_history(&operations).ok() + } _ => None, } } @@ -1122,6 +1151,38 @@ impl Scene { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; + if grip_id == crate::scene::model::solid_history::GRIP_FILLET_RADIUS { + let acadrust::objects::SolidHistoryOperation::Fillet(value) = &mut operation else { + return false; + }; + let Some(radius) = value.radii.first().copied() else { + return false; + }; + self.restore_solid_models(&[handle]); + let Some(definition) = self + .solid_models + .get(&handle) + .and_then(|body| { + crate::scene::model::solid_history::fillet_radius_grip(body, radius) + }) + else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let radius = radius + change; + if !radius.is_finite() || radius <= 1.0e-6 { + return false; + } + value.radii.clear(); + value.radii.push(radius); + return self.preview_solid_history(handle, operation); + } if !crate::scene::model::solid_history::apply_primitive_grip( &mut operation, grip_id, From d426012660ea7559343ee6bf674f00415bb398ef Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:52:18 +0300 Subject: [PATCH 10/40] Execute command scripts through full input runner --- src/app/commands/fileops.rs | 10 ++++------ src/app/mod.rs | 6 +++++- src/app/update/mod.rs | 8 ++++++++ src/main.rs | 4 +++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 5b68856ea..e1237595b 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -335,9 +335,9 @@ impl OpenCADStudio { "PERSP" => return Some(Task::done(Message::SetProjection(false))), "LAYERS" => return Some(Task::done(Message::ToggleLayers)), - // SCRIPT — run a command script: each non-blank, non-comment + // SCRIPT — run a command script: each non-comment // line is fed through the same command path the `--script` startup - // flag uses, so the behaviour matches headless automation exactly. + // flag uses. Blank rows submit Enter to the active command. "SCRIPT" | "SCR" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); @@ -352,10 +352,8 @@ impl OpenCADStudio { let cmds: Vec> = text .lines() .map(str::trim) - .filter(|l| { - !l.is_empty() && !l.starts_with('#') && !l.starts_with(';') - }) - .map(|l| Task::done(Message::Command(l.to_string()))) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + .map(|l| Task::done(Message::ScriptLine(l.to_string()))) .collect(); self.command_line.push_output(crate::tf!( "SCRIPT: running {} command(s) from {p}.", diff --git a/src/app/mod.rs b/src/app/mod.rs index d201787c3..c9dbd3cbf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2117,6 +2117,10 @@ pub enum Message { CommandInput(String), CommandSubmit, Command(String), + /// Execute one complete line read from a command script. Unlike UI/ribbon + /// dispatch, this accepts an interactive verb and all of its arguments on + /// the same line (`BOX 0,0,0 10,10,0 10`). + ScriptLine(String), /// Append one typed character to the command-line input from the /// global key-press subscription. Used when the text-input widget /// itself isn't focused (focus parked on viewport / button / etc.) @@ -3832,7 +3836,7 @@ impl OpenCADStudio { Task::batch( cfg.script_lines .into_iter() - .map(|line| Task::done(Message::Command(line))), + .map(|line| Task::done(Message::ScriptLine(line))), ) }; s.queue_startup_prompts(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b80f9ee7c..ed34e2c6e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1953,6 +1953,14 @@ impl OpenCADStudio { self.dispatch_command(&cmd) } + Message::ScriptLine(line) => { + if line.trim().is_empty() { + self.feed_command(crate::command::StepInput::Enter) + } else { + self.run_command_line(&line) + } + } + Message::ToggleLayers => { if self.active_modal == Some(super::ModalKind::Layers) { self.ribbon.deactivate_tool_if("LAYERS"); diff --git a/src/main.rs b/src/main.rs index f197aa6f9..a691981e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,7 +134,9 @@ fn main() -> iced::Result { Ok(text) => text .lines() .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) + // Blank script rows are significant: they submit Enter to + // the active command (for example, accepting a preview). + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) .map(str::to_string) .collect(), Err(e) => { From 53bd85d8a0f2401edc774b37a878e17bca5021b8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:00:22 +0300 Subject: [PATCH 11/40] Use kernel corner and analytic profile fillets --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4aceb44e..7006b3144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=a1286c8fe2223973293c1010c7f8329e4ba0daf5#a1286c8fe2223973293c1010c7f8329e4ba0daf5" +source = "git+https://github.com/ramox81/cadkernel.git?rev=31b19e69ab6edced5f3563c9a12c974268dcb8b0#31b19e69ab6edced5f3563c9a12c974268dcb8b0" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 82788538f..9e9ac910e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "a1286c8fe2223973293c1010c7f8329e4ba0daf5" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "31b19e69ab6edced5f3563c9a12c974268dcb8b0" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From ed8cddc5566a616bc5c94562c33972a218413087 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:16:30 +0300 Subject: [PATCH 12/40] Pin analytic pcurve serialization and spherical corner fixes --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7006b3144..b85f7a77b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/ramox81/cadcodec.git?rev=1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d#1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" +source = "git+https://github.com/ramox81/cadcodec.git?rev=af1f31287d4730f228cbb27d6884078295627ca7#af1f31287d4730f228cbb27d6884078295627ca7" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=31b19e69ab6edced5f3563c9a12c974268dcb8b0#31b19e69ab6edced5f3563c9a12c974268dcb8b0" +source = "git+https://github.com/ramox81/cadkernel.git?rev=f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536#f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 9e9ac910e..3fb177f53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,10 +74,10 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1c7b30ff62aef5dd934c91bf8dd1cbdeb3d7155d" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "31b19e69ab6edced5f3563c9a12c974268dcb8b0" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 84e95e4781df9baa24204af5bae45cfeebbcc266 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 13:13:30 +0100 Subject: [PATCH 13/40] Configure 16 MB stack reserve on Windows for debug builds --- .cargo/config.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 88c43407a..de96c3e4a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -18,3 +18,13 @@ rustflags = [ # WebAssembly stack while developing with `trunk serve`. '-Clink-arg=-zstack-size=4194304', ] + +[target.x86_64-pc-windows-gnu] +rustflags = [ + "-Clink-arg=-Wl,--stack,16777216", +] + +[target.x86_64-pc-windows-msvc] +rustflags = [ + "-Clink-arg=/STACK:16777216", +] From ec3b6b89f6cffc3835971b0b87e44daae0677bc4 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 13:13:36 +0100 Subject: [PATCH 14/40] Expand GPU analytical curves to interactive previews and transformations --- src/modules/draw/draw/arc.rs | 21 +- src/modules/draw/draw/circle.rs | 17 +- src/modules/draw/draw/ellipse.rs | 13 +- src/scene/model/wire_model.rs | 316 ++++++++++++++++++++++++++++++ src/scene/pipeline/circle_gpu.rs | 2 +- src/scene/pipeline/ellipse_gpu.rs | 2 +- src/scene/pipeline/mod.rs | 68 ++++++- src/scene/pipeline/wire_arena.rs | 44 +++++ 8 files changed, 464 insertions(+), 19 deletions(-) diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index 80e8f2da1..c1851af62 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -7,7 +7,7 @@ use cadkernel::geom2d::{self, Curve as KernelCurve}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::IconKind; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; use glam::DVec3; const TAU: f64 = std::f64::consts::TAU; @@ -71,9 +71,9 @@ fn arc_preview( end_angle: f64, plane: WorkingPlane, ) -> Option { - let center = plane.to_local(center); + let center_local = plane.to_local(center); let arc = geom2d::bounded_arc( - [center.x, center.y], + [center_local.x, center_local.y], radius, start_angle, end_angle, @@ -81,14 +81,23 @@ fn arc_preview( let points = KernelCurve::Arc(arc) .tessellate_angle(TAU / 64.0) .into_iter() - .map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array()) + .map(|point| plane.to_world(DVec3::new(point[0], point[1], center_local.z)).to_array()) .collect(); - Some(WireModel::solid_f64( + let mut wire = WireModel::solid_f64( "rubber_band".into(), points, WireModel::CYAN, false, - )) + ); + wire.tangent_geoms.push(TangentGeom::Arc { + center: [center.x, center.y, center.z], + axis_x: plane.x.to_array(), + axis_y: plane.y.to_array(), + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }); + Some(wire) } fn make_arc( diff --git a/src/modules/draw/draw/circle.rs b/src/modules/draw/draw/circle.rs index 12c684bf5..38dc0731f 100644 --- a/src/modules/draw/draw/circle.rs +++ b/src/modules/draw/draw/circle.rs @@ -11,7 +11,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, DynField, TangentObject, WorkingPlane}; use crate::modules::draw::defaults; use crate::modules::IconKind; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; use glam::DVec3; const TAU: f64 = std::f64::consts::TAU; @@ -56,16 +56,23 @@ pub const ICON: IconKind = ICON_CR; // ── Shared geometry ──────────────────────────────────────────────────────── fn circle_wire(center: DVec3, radius: f64, plane: WorkingPlane) -> WireModel { - let center = plane.to_local(center); + let center_local = plane.to_local(center); let points = KernelCurve::Circle(KernelCircle { - centre: [center.x, center.y], + centre: [center_local.x, center_local.y], radius, }) .tessellate_angle(TAU / 64.0) .into_iter() - .map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array()) + .map(|point| plane.to_world(DVec3::new(point[0], point[1], center_local.z)).to_array()) .collect(); - WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false) + let mut wire = WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false); + wire.tangent_geoms.push(TangentGeom::PlanarCircle { + center: [center.x, center.y, center.z], + axis_x: plane.x.to_array(), + axis_y: plane.y.to_array(), + radius, + }); + wire } fn make_circle(center: DVec3, radius: f64, plane: WorkingPlane) -> EntityType { diff --git a/src/modules/draw/draw/ellipse.rs b/src/modules/draw/draw/ellipse.rs index 1b975efde..db8af61c4 100644 --- a/src/modules/draw/draw/ellipse.rs +++ b/src/modules/draw/draw/ellipse.rs @@ -11,7 +11,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::IconKind; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; use glam::DVec3; fn parse_num(text: &str) -> Option { @@ -75,7 +75,16 @@ fn ellipse_wire( [p.x, p.y, p.z] }) .collect(); - WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false) + let mut wire = WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false); + wire.tangent_geoms.push(TangentGeom::PlanarEllipse { + center: [center.x, center.y, center.z], + major_axis: [major.x, major.y, major.z], + normal: plane.z.to_array(), + minor_axis_ratio: ratio, + start_param: t_start, + end_param: t_e, + }); + wire } /// Convert a world point to the parametric angle on the ellipse. diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 1f76e7129..30905d7a3 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -58,6 +58,201 @@ pub enum TangentGeom { }, } +impl TangentGeom { + pub fn translated(&self, delta: glam::DVec3) -> Self { + match self { + Self::Line { p1, p2 } => Self::Line { + p1: [p1[0] + delta.x as f32, p1[1] + delta.y as f32, p1[2] + delta.z as f32], + p2: [p2[0] + delta.x as f32, p2[1] + delta.y as f32, p2[2] + delta.z as f32], + }, + Self::Circle { center, radius } => Self::Circle { + center: [center[0] + delta.x as f32, center[1] + delta.y as f32, center[2] + delta.z as f32], + radius: *radius, + }, + Self::PlanarCircle { center, axis_x, axis_y, radius } => Self::PlanarCircle { + center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z], + axis_x: *axis_x, + axis_y: *axis_y, + radius: *radius, + }, + Self::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc { + center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z], + axis_x: *axis_x, + axis_y: *axis_y, + radius: *radius, + start_angle: *start_angle, + end_angle: *end_angle, + }, + Self::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse { + center: [center[0] + delta.x, center[1] + delta.y, center[2] + delta.z], + major_axis: *major_axis, + normal: *normal, + minor_axis_ratio: *minor_axis_ratio, + start_param: *start_param, + end_param: *end_param, + }, + } + } + + pub fn rotated(&self, center: glam::DVec3, axis: glam::DVec3, angle_rad: f64) -> Self { + let quat = glam::DQuat::from_axis_angle(axis.normalize_or(glam::DVec3::Z), angle_rad); + let rot_pt = |p: [f64; 3]| (center + quat * (glam::DVec3::from_array(p) - center)).to_array(); + let rot_vec = |v: [f64; 3]| (quat * glam::DVec3::from_array(v)).to_array(); + match self { + Self::Line { p1, p2 } => { + let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64]; + let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64]; + let r1 = rot_pt(p1_d); + let r2 = rot_pt(p2_d); + Self::Line { + p1: [r1[0] as f32, r1[1] as f32, r1[2] as f32], + p2: [r2[0] as f32, r2[1] as f32, r2[2] as f32], + } + } + Self::Circle { center: c, radius } => { + let c_d = [c[0] as f64, c[1] as f64, c[2] as f64]; + let rc = rot_pt(c_d); + Self::Circle { + center: [rc[0] as f32, rc[1] as f32, rc[2] as f32], + radius: *radius, + } + } + Self::PlanarCircle { center: c, axis_x, axis_y, radius } => Self::PlanarCircle { + center: rot_pt(*c), + axis_x: rot_vec(*axis_x), + axis_y: rot_vec(*axis_y), + radius: *radius, + }, + Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc { + center: rot_pt(*c), + axis_x: rot_vec(*axis_x), + axis_y: rot_vec(*axis_y), + radius: *radius, + start_angle: *start_angle, + end_angle: *end_angle, + }, + Self::PlanarEllipse { center: c, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse { + center: rot_pt(*c), + major_axis: rot_vec(*major_axis), + normal: rot_vec(*normal), + minor_axis_ratio: *minor_axis_ratio, + start_param: *start_param, + end_param: *end_param, + }, + } + } + + pub fn scaled(&self, center: glam::DVec3, factor: f64) -> Self { + let scale_pt = |p: [f64; 3]| (center + (glam::DVec3::from_array(p) - center) * factor).to_array(); + match self { + Self::Line { p1, p2 } => { + let p1_d = [p1[0] as f64, p1[1] as f64, p1[2] as f64]; + let p2_d = [p2[0] as f64, p2[1] as f64, p2[2] as f64]; + let s1 = scale_pt(p1_d); + let s2 = scale_pt(p2_d); + Self::Line { + p1: [s1[0] as f32, s1[1] as f32, s1[2] as f32], + p2: [s2[0] as f32, s2[1] as f32, s2[2] as f32], + } + } + Self::Circle { center: c, radius } => { + let c_d = [c[0] as f64, c[1] as f64, c[2] as f64]; + let sc = scale_pt(c_d); + Self::Circle { + center: [sc[0] as f32, sc[1] as f32, sc[2] as f32], + radius: *radius * factor.abs() as f32, + } + } + Self::PlanarCircle { center: c, axis_x, axis_y, radius } => Self::PlanarCircle { + center: scale_pt(*c), + axis_x: *axis_x, + axis_y: *axis_y, + radius: *radius * factor.abs(), + }, + Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => Self::Arc { + center: scale_pt(*c), + axis_x: *axis_x, + axis_y: *axis_y, + radius: *radius * factor.abs(), + start_angle: *start_angle, + end_angle: *end_angle, + }, + Self::PlanarEllipse { center: c, major_axis, normal, minor_axis_ratio, start_param, end_param } => Self::PlanarEllipse { + center: scale_pt(*c), + major_axis: (glam::DVec3::from_array(*major_axis) * factor.abs()).to_array(), + normal: *normal, + minor_axis_ratio: *minor_axis_ratio, + start_param: *start_param, + end_param: *end_param, + }, + } + } + + pub fn mirrored(&self, p1: glam::DVec3, normal: glam::DVec3) -> Self { + let refl_pt = |p: [f64; 3]| { + let pt = glam::DVec3::from_array(p); + (pt - 2.0 * normal.dot(pt - p1) * normal).to_array() + }; + let refl_vec = |v: [f64; 3]| { + let vec = glam::DVec3::from_array(v); + (vec - 2.0 * normal.dot(vec) * normal).to_array() + }; + match self { + Self::Line { p1: lp1, p2: lp2 } => { + let p1_d = [lp1[0] as f64, lp1[1] as f64, lp1[2] as f64]; + let p2_d = [lp2[0] as f64, lp2[1] as f64, lp2[2] as f64]; + let m1 = refl_pt(p1_d); + let m2 = refl_pt(p2_d); + Self::Line { + p1: [m1[0] as f32, m1[1] as f32, m1[2] as f32], + p2: [m2[0] as f32, m2[1] as f32, m2[2] as f32], + } + } + Self::Circle { center: c, radius } => { + let c_d = [c[0] as f64, c[1] as f64, c[2] as f64]; + let mc = refl_pt(c_d); + Self::Circle { + center: [mc[0] as f32, mc[1] as f32, mc[2] as f32], + radius: *radius, + } + } + Self::PlanarCircle { center: c, axis_x, axis_y, radius } => { + let ax = refl_vec(*axis_x); + let ay = refl_vec(*axis_y); + Self::PlanarCircle { + center: refl_pt(*c), + axis_x: ax, + axis_y: [-ay[0], -ay[1], -ay[2]], + radius: *radius, + } + } + Self::Arc { center: c, axis_x, axis_y, radius, start_angle, end_angle } => { + let ax = refl_vec(*axis_x); + let ay = refl_vec(*axis_y); + Self::Arc { + center: refl_pt(*c), + axis_x: ax, + axis_y: [-ay[0], -ay[1], -ay[2]], + radius: *radius, + start_angle: -end_angle, + end_angle: -start_angle, + } + } + Self::PlanarEllipse { center: c, major_axis, normal: n, minor_axis_ratio, start_param, end_param } => { + let norm = refl_vec(*n); + Self::PlanarEllipse { + center: refl_pt(*c), + major_axis: refl_vec(*major_axis), + normal: [-norm[0], -norm[1], -norm[2]], + minor_axis_ratio: *minor_axis_ratio, + start_param: *start_param, + end_param: *end_param, + } + } + } + } +} + /// Viewport-relative display transform for a point marker. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PointMarker { @@ -413,6 +608,11 @@ impl WireModel { out.text_verts = map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz)); } + out.tangent_geoms = self + .tangent_geoms + .iter() + .map(|tg| tg.translated(delta.as_dvec3())) + .collect(); out } @@ -465,6 +665,11 @@ impl WireModel { (mapped.x, mapped.y, mapped.z) }); } + out.tangent_geoms = self + .tangent_geoms + .iter() + .map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64)) + .collect(); out } @@ -499,6 +704,11 @@ impl WireModel { (cx + (x - cx) * f, cy + (y - cy) * f, cz + (z - cz) * f) }); } + out.tangent_geoms = self + .tangent_geoms + .iter() + .map(|tg| tg.scaled(center.as_dvec3(), factor as f64)) + .collect(); out } @@ -639,6 +849,11 @@ pub fn stretched_windows( (mapped.x, mapped.y, mapped.z) }); } + out.tangent_geoms = self + .tangent_geoms + .iter() + .map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3())) + .collect(); out } @@ -764,3 +979,104 @@ impl Default for WireModel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_transformations_preserve_and_update_tangent_geoms() { + let mut wire = WireModel::default(); + wire.tangent_geoms.push(TangentGeom::PlanarCircle { + center: [10.0, 20.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + }); + wire.tangent_geoms.push(TangentGeom::Arc { + center: [10.0, 20.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: 0.0, + end_angle: std::f64::consts::FRAC_PI_2, + }); + wire.tangent_geoms.push(TangentGeom::PlanarEllipse { + center: [10.0, 20.0, 0.0], + major_axis: [10.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: 0.0, + end_param: std::f64::consts::PI, + }); + + // Test translated + let trans = wire.translated(glam::Vec3::new(5.0, -5.0, 1.0)); + assert_eq!(trans.tangent_geoms.len(), 3); + if let TangentGeom::PlanarCircle { center, radius, .. } = trans.tangent_geoms[0] { + assert!((center[0] - 15.0).abs() < 1e-5); + assert!((center[1] - 15.0).abs() < 1e-5); + assert!((center[2] - 1.0).abs() < 1e-5); + assert!((radius - 5.0).abs() < 1e-5); + } else { + panic!("Expected PlanarCircle"); + } + if let TangentGeom::Arc { center, radius, start_angle, end_angle, .. } = trans.tangent_geoms[1] { + assert!((center[0] - 15.0).abs() < 1e-5); + assert!((center[1] - 15.0).abs() < 1e-5); + assert!((center[2] - 1.0).abs() < 1e-5); + assert!((radius - 5.0).abs() < 1e-5); + assert_eq!(start_angle, 0.0); + assert_eq!(end_angle, std::f64::consts::FRAC_PI_2); + } else { + panic!("Expected Arc"); + } + if let TangentGeom::PlanarEllipse { center, major_axis, minor_axis_ratio, .. } = trans.tangent_geoms[2] { + assert!((center[0] - 15.0).abs() < 1e-5); + assert!((center[1] - 15.0).abs() < 1e-5); + assert!((center[2] - 1.0).abs() < 1e-5); + assert_eq!(major_axis, [10.0, 0.0, 0.0]); + assert_eq!(minor_axis_ratio, 0.5); + } else { + panic!("Expected PlanarEllipse"); + } + + // Test scaled + let scaled = wire.scaled(glam::Vec3::new(10.0, 20.0, 0.0), 2.0); + if let TangentGeom::PlanarCircle { radius, .. } = scaled.tangent_geoms[0] { + assert!((radius - 10.0).abs() < 1e-5); + } else { + panic!("Expected PlanarCircle"); + } + if let TangentGeom::Arc { radius, .. } = scaled.tangent_geoms[1] { + assert!((radius - 10.0).abs() < 1e-5); + } else { + panic!("Expected Arc"); + } + if let TangentGeom::PlanarEllipse { major_axis, .. } = scaled.tangent_geoms[2] { + assert!((major_axis[0] - 20.0).abs() < 1e-5); + } else { + panic!("Expected PlanarEllipse"); + } + + // Test rotated + let rot = wire.rotated(glam::Vec3::ZERO, std::f32::consts::FRAC_PI_2); + if let TangentGeom::PlanarCircle { center, axis_x, axis_y, .. } = rot.tangent_geoms[0] { + assert!((center[0] - -20.0).abs() < 1e-4); + assert!((center[1] - 10.0).abs() < 1e-4); + assert!((axis_x[1] - 1.0).abs() < 1e-4); + assert!((axis_y[0] - -1.0).abs() < 1e-4); + } else { + panic!("Expected PlanarCircle"); + } + + // Test mirrored across Y=0 plane (normal = Y) + let mirr = wire.mirrored(glam::Vec3::new(0.0, 0.0, 0.0), glam::Vec3::new(1.0, 0.0, 0.0)); + if let TangentGeom::PlanarCircle { center, .. } = mirr.tangent_geoms[0] { + assert!((center[0] - 10.0).abs() < 1e-4); + assert!((center[1] - -20.0).abs() < 1e-4); + } else { + panic!("Expected PlanarCircle"); + } + } +} diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index 92d7332fb..d62902a64 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -237,7 +237,7 @@ pub fn extract_circle_instance_from_geom( ), _ => return None, }; - if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() { + if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() || radius > 1e6 { return None; } diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs index 1291be8f5..50bc2e59d 100644 --- a/src/scene/pipeline/ellipse_gpu.rs +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -218,7 +218,7 @@ pub fn extract_ellipse_instance_from_geom( let major_len_sq = major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2]; let norm_len_sq = normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]; - if major_len_sq <= 1e-12 || !major_len_sq.is_finite() + if major_len_sq <= 1e-12 || !major_len_sq.is_finite() || major_len_sq > 1e12 || norm_len_sq <= 1e-12 || !norm_len_sq.is_finite() || minor_axis_ratio <= 1e-6 || !minor_axis_ratio.is_finite() || !start_param.is_finite() || !end_param.is_finite() diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 9e27c2a00..c77fa6d2d 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -273,6 +273,8 @@ pub struct Pipeline { /// frame they are present (small), drawn on top of the base wire pass — so /// a live drag never re-uploads the resident base buffer. gpu_preview_wires: Vec, + gpu_preview_circles: Vec, + gpu_preview_ellipses: Vec, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, @@ -2346,6 +2348,8 @@ impl Pipeline { gpu_selected_ellipses: vec![], gpu_selected_block_wires: vec![], gpu_preview_wires: vec![], + gpu_preview_circles: vec![], + gpu_preview_ellipses: vec![], gpu_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], @@ -2940,11 +2944,47 @@ impl Pipeline { wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, ) { - self.gpu_preview_wires = if wires.is_empty() { - vec![] + if wires.is_empty() { + self.gpu_preview_wires.clear(); + self.gpu_preview_circles.clear(); + self.gpu_preview_ellipses.clear(); } else { - WireGpu::from_run(device, queue, wires, depth_map, false, self.wire_const_bgl.as_ref()) - }; + let partitioned = wire_arena::partition_wires(wires, depth_map); + let mut non_analytical = partitioned.regular; + non_analytical.extend(partitioned.mesh); + self.gpu_preview_wires = if non_analytical.is_empty() { + vec![] + } else { + WireGpu::from_run_refs( + device, + queue, + &non_analytical, + depth_map, + false, + self.wire_const_bgl.as_ref(), + ) + }; + self.gpu_preview_circles = if partitioned.circle_instances.is_empty() { + vec![] + } else { + vec![CircleGpu::from_instances( + device, + queue, + "preview.circles", + &partitioned.circle_instances, + )] + }; + self.gpu_preview_ellipses = if partitioned.ellipse_instances.is_empty() { + vec![] + } else { + vec![EllipseGpu::from_instances( + device, + queue, + "preview.ellipses", + &partitioned.ellipse_instances, + )] + }; + } } /// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded @@ -4611,6 +4651,24 @@ impl Pipeline { } } } + if self.gpu_preview_circles.iter().any(|cg| cg.instance_count > 0) { + pass.set_pipeline(&self.circle_xray_pipeline); + for cg in &self.gpu_preview_circles { + if cg.instance_count > 0 { + pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); + pass.draw(0..6, 0..cg.instance_count); + } + } + } + if self.gpu_preview_ellipses.iter().any(|eg| eg.instance_count > 0) { + pass.set_pipeline(&self.ellipse_xray_pipeline); + for eg in &self.gpu_preview_ellipses { + if eg.instance_count > 0 { + pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); + pass.draw(0..6, 0..eg.instance_count); + } + } + } } // ── Pass 5c: SDF text quads (drawn over wires) ──────────────────── @@ -4970,6 +5028,8 @@ impl Pipeline { self.gpu_selected_circles.clear(); self.gpu_selected_ellipses.clear(); self.gpu_preview_wires.clear(); + self.gpu_preview_circles.clear(); + self.gpu_preview_ellipses.clear(); self.hatch_gpu.clear(); self.gpu_wipeouts.clear(); self.wipeout_skip_flags.clear(); diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index d4e927da4..bbaddb773 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -1644,4 +1644,48 @@ mod tests { assert!(blank_const().pattern_length < 0.0); assert!(blank_packed_instance().pattern_length < 0.0); } + + #[test] + fn partition_wires_partitions_preview_analytical_curves() { + use crate::scene::model::wire_model::TangentGeom; + let mut circle_preview = WireModel::default(); + circle_preview.tangent_geoms.push(TangentGeom::PlanarCircle { + center: [1.0, 2.0, 3.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 10.0, + }); + + let mut arc_preview = WireModel::default(); + arc_preview.tangent_geoms.push(TangentGeom::Arc { + center: [4.0, 5.0, 6.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 8.0, + start_angle: 0.1, + end_angle: 2.0, + }); + + let mut ellipse_preview = WireModel::default(); + ellipse_preview.tangent_geoms.push(TangentGeom::PlanarEllipse { + center: [7.0, 8.0, 9.0], + major_axis: [5.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.6, + start_param: 0.0, + end_param: std::f64::consts::TAU, + }); + + let mut regular_line = WireModel::default(); + regular_line.points.push([0.0, 0.0, 0.0]); + regular_line.points.push([1.0, 1.0, 1.0]); + + let wires = vec![circle_preview, arc_preview, ellipse_preview, regular_line]; + let depth_map = rustc_hash::FxHashMap::default(); + let partitioned = partition_wires(&wires, &depth_map); + + assert_eq!(partitioned.circle_instances.len(), 2); + assert_eq!(partitioned.ellipse_instances.len(), 1); + assert_eq!(partitioned.regular.len(), 1); + } } From a754fac6d43d181cb424f179ef122988a94a956d Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 13:13:41 +0100 Subject: [PATCH 15/40] Attach analytical arc to polyline Arc mode rubber-band preview --- src/app/automation.rs | 59 +++++++++++++++++++++++++ src/modules/draw/draw/polyline.rs | 67 +++++++++++++++++++++++++--- src/scene/convert/tessellate.rs | 32 ++++++++++++-- src/scene/pipeline/gpu_tests.rs | 73 +++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 8 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index e0b11eb66..6d7b4b05e 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1265,4 +1265,63 @@ mod tests { let _ = std::fs::remove_file(sidecar); let _ = std::fs::remove_file(&path); } + + #[test] + fn test_pline_line_then_arc() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + { + app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0); + } + + // Start PLINE + let _ = app.update(Message::CommandInput("PLINE".to_string())); + let _ = app.update(Message::CommandSubmit); + + // Click first point (100, 100) + let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 100.0))); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + // Move to (200, 100) and click second point (draw line) + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0))); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + let wid = app.main_window.unwrap_or_else(iced::window::Id::unique); + + // Switch to arc: Option 1 - CommandOptionPick("A") + let _ = app.update(Message::CommandOptionPick("A".to_string())); + let _ = app.view(wid); + println!("ENTITIES: {}", app.tabs[0].scene.document.entities().count()); + println!("CMD: {:?}", app.tabs[0].active_cmd.as_ref().map(|c| c.name())); + + // Move mouse! + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(201.0, 100.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 150.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 150.0))); + let _ = app.view(wid); + + // Click arc point + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + // Move mouse again + let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 160.0))); + + // Switch to line + let _ = app.update(Message::CommandOptionPick("L".to_string())); + + // Move mouse again + let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 150.0))); + + // Finish + let _ = app.update(Message::CommandOptionPick(String::new())); + } } diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index 61542c690..852c42400 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -17,7 +17,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; // ── Ribbon definition ────────────────────────────────────────────────────── @@ -443,6 +443,7 @@ impl CadCommand for PlineCommand { let pt = self.plane.to_local(pt_world).as_vec3(); let mut pts: Vec<[f32; 3]> = Vec::new(); + let mut tangent_geom = None; match self.mode { SegMode::Line => { pts.push([last.x, last.y, last.z]); @@ -456,8 +457,44 @@ impl CadCommand for PlineCommand { .map(|t| t.as_dvec2()) .unwrap_or(DVec2::new(1.0, 0.0)); let bulge = compute_bulge(a, tangent, b); - let arc_pts = arc_sample_points(last, bulge, pt, 16); - pts.extend_from_slice(&arc_pts); + if bulge.abs() >= 1e-9 && (b - a).length_squared() >= 1e-12 { + if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], bulge) + { + for s in arc.tessellate_angle(std::f64::consts::TAU / 64.0) { + pts.push([s[0] as f32, s[1] as f32, last.z]); + } + let (sa, ea) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + let world_center = self + .plane + .to_world(DVec3::new(arc.center[0], arc.center[1], last.z as f64)); + if arc.radius > 0.0 + && arc.radius.is_finite() + && arc.radius <= 1e6 + && sa.is_finite() + && ea.is_finite() + { + tangent_geom = Some(TangentGeom::Arc { + center: world_center.to_array(), + axis_x: self.plane.x.to_array(), + axis_y: self.plane.y.to_array(), + radius: arc.radius, + start_angle: sa, + end_angle: ea, + }); + } + } else { + let arc_pts = arc_sample_points(last, bulge, pt, 64); + pts.extend_from_slice(&arc_pts); + } + } else { + pts.push([last.x, last.y, last.z]); + pts.push([pt.x, pt.y, pt.z]); + } } } @@ -465,12 +502,16 @@ impl CadCommand for PlineCommand { .into_iter() .map(|point| self.plane.to_world(Vec3::from_array(point).as_dvec3()).as_vec3().to_array()) .collect(); - Some(WireModel::solid( + let mut wire = WireModel::solid( "rubber_band".into(), world_points, WireModel::CYAN, false, - )) + ); + if let Some(tg) = tangent_geom { + wire.tangent_geoms.push(tg); + } + Some(wire) } } @@ -548,4 +589,20 @@ mod tests { CmdResult::FinalizeLiveEntity(h) if h == handle )); } + + #[test] + fn test_draw_line_then_arc() { + let mut cmd = PlineCommand::new(); + cmd.on_point(DVec3::new(0.0, 0.0, 0.0)); + let res = cmd.on_point(DVec3::new(10.0, 0.0, 0.0)); + assert!(matches!(res, CmdResult::CommitLiveEntity(_))); + cmd.set_live_handle(Handle::new(1)); + cmd.on_text_input("A"); + let wire = cmd.on_mouse_move(DVec3::new(10.0, 5.0, 0.0)).expect("arc wire"); + assert_eq!(wire.tangent_geoms.len(), 1); + assert!(matches!(wire.tangent_geoms[0], TangentGeom::Arc { .. })); + assert!(wire.points.len() >= 16); + let res2 = cmd.on_point(DVec3::new(10.0, 5.0, 0.0)); + assert!(matches!(res2, CmdResult::UpdateLiveEntity { .. })); + } } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 31762e586..cbbfacd7a 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -219,7 +219,33 @@ fn split_mixed_polyline( // Emit each arc segment as an analytical wire (CircleGpu target) for tg in tangent_geoms { - if matches!(tg, TangentGeom::Arc { .. }) { + if let TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } = *tg + { + let mut arc_pts = Vec::with_capacity(17); + let n = 16; + let sweep = if end_angle >= start_angle { + end_angle - start_angle + } else { + end_angle - start_angle + std::f64::consts::TAU + }; + for s in 0..=n { + let frac = s as f64 / n as f64; + let ang = start_angle + frac * sweep; + let p = [ + center[0] + radius * (ang.cos() * axis_x[0] + ang.sin() * axis_y[0]), + center[1] + radius * (ang.cos() * axis_x[1] + ang.sin() * axis_y[1]), + center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]), + ]; + arc_pts.push(p); + } + let (points, points_low) = points_to_ds(arc_pts); out.push(WireModel { bg_adapt: None, point_marker: None, @@ -238,8 +264,8 @@ fn split_mixed_polyline( dash_align_end: None, text_verts: Vec::new(), name: name.to_string(), - points: Vec::new(), - points_low: Vec::new(), + points, + points_low, color, selected, pattern_length, diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 90064d106..0660c95e5 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -408,3 +408,76 @@ fn late_device_errors_invalidate_uploaded_content_and_targets() { "the same error must not invalidate twice" ); } + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_pline_arc_switch_preview_and_render() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + use crate::command::CadCommand; + + let mut cmd = crate::modules::draw::draw::polyline::PlineCommand::new(); + cmd.on_point(glam::DVec3::new(0.0, 0.0, 0.0)); + let _res = cmd.on_point(glam::DVec3::new(10.0, 0.0, 0.0)); + cmd.set_live_handle(acadrust::Handle::new(1)); + cmd.on_text_input("A"); + + let test_points = [ + glam::DVec3::new(10.0, 0.0, 0.0), + glam::DVec3::new(10.0000001, 0.0, 0.0), + glam::DVec3::new(10.0, 0.0000001, 0.0), + glam::DVec3::new(10.0, -0.0000001, 0.0), + glam::DVec3::new(9.9999999, 0.0, 0.0), + glam::DVec3::new(15.0, 5.0, 0.0), + glam::DVec3::new(10.0, 10.0, 0.0), + glam::DVec3::new(5.0, 5.0, 0.0), + glam::DVec3::new(0.0, 0.0, 0.0), + ]; + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + for pt in test_points { + let wire = cmd.on_mouse_move(pt); + if let Some(w) = wire { + pipeline.upload_preview_wires(&device, &queue, &[w], &depth); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + } + } +} From 8822371e18cf524c3962a82bb31bbf04f3d311d1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:45:10 +0300 Subject: [PATCH 16/40] Complete CHAMFEREDGE workflow and history --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 11 +- src/app/commands/dim.rs | 57 +++-- src/app/model_ops.rs | 231 +++++++++++++------- src/command.rs | 2 + src/modules/model/edge_cmd.rs | 361 +++++++++++++++++++++++++------ src/modules/model/mod.rs | 2 +- src/scene/model/solid_history.rs | 126 ++++++++++- src/scene/modify.rs | 41 ++++ 10 files changed, 678 insertions(+), 157 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b85f7a77b..c45a8f9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536#f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" +source = "git+https://github.com/ramox81/cadkernel.git?rev=2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4#2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 3fb177f53..1d8f7725b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b0db25725..128dad867 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4105,10 +4105,19 @@ impl OpenCADStudio { CmdResult::SolidEdgeBlend { handle, edges, + base_face, value, + other_value, fillet, } => { - let task = self.solid_edge_blend(handle, &edges, value, fillet); + let task = self.solid_edge_blend( + handle, + &edges, + base_face, + value, + other_value, + fillet, + ); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index c083f6f60..8adf3b441 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,6 +1,6 @@ use super::*; -fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { +fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option { let scene = &app.tabs.get(tab)?.scene; let selected = scene.selected_handles_in_order(); let [handle] = selected.as_slice() else { @@ -9,7 +9,7 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { if matches!( scene.document.get_entity(*handle), - Some(acadrust::EntityType::Solid3D(_)) + Some(acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_)) ) { Some(*handle) } else { @@ -26,7 +26,10 @@ fn solid_edge_sources( .document .entities() .filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)) + matches!( + entity, + acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_) + ) .then_some(entity.common().handle) }) .collect::>(); @@ -989,7 +992,7 @@ impl OpenCADStudio { "FILLETEDGE" | "SOLIDFILLET" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + let target = selected_edge_body(self, i); let bodies = solid_edge_sources(self, i); let command = SolidEdgeCommand::new( EdgeOperation::Fillet, @@ -1001,9 +1004,9 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(command)); } - "FILLET" if selected_solid(self, i).is_some() => { + "FILLET" if selected_edge_body(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + let target = selected_edge_body(self, i); let bodies = solid_edge_sources(self, i); let command = SolidEdgeCommand::new( EdgeOperation::Fillet, @@ -1148,29 +1151,51 @@ impl OpenCADStudio { } } - "SOLIDCHAMFER" => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + "CHAMFEREDGE" | "SOLIDCHAMFER" => { + use crate::modules::model::edge_cmd::SolidEdgeCommand; + let target = selected_edge_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); let bodies = solid_edge_sources(self, i); - let command = SolidEdgeCommand::new( - EdgeOperation::Chamfer, + let command = SolidEdgeCommand::new_chamfer( target, bodies, crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.chamfer_distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } - "CHAMFER" if selected_solid(self, i).is_some() => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let target = selected_solid(self, i); + "CHAMFER" if selected_edge_body(self, i).is_some() => { + use crate::modules::model::edge_cmd::SolidEdgeCommand; + let target = selected_edge_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); let bodies = solid_edge_sources(self, i); - let command = SolidEdgeCommand::new( - EdgeOperation::Chamfer, + let command = SolidEdgeCommand::new_chamfer( target, bodies, crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.chamfer_distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 16e87d6f1..17c39b765 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -7,7 +7,7 @@ use acadrust::{ objects::SolidHistoryOperation, EntityType, Handle, }; -use cadkernel::brep::{Body, EdgeKey}; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use iced::Task; use std::collections::HashMap; @@ -244,6 +244,29 @@ fn entity_with_boolean_body(mut source: EntityType, body: &Body) -> Option Option { + let document = crate::scene::convert::acis_export::solid_to_sat(body)?; + let wires = solid_model::edge_wires(body); + match &mut source { + EntityType::Solid3D(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + if entity.kind != SurfaceKind::Plane { + entity.kind = SurfaceKind::Generic; + entity.surface_data = SurfaceData::Generic; + } + entity.acis_data = AcisData::from_sat(&document.to_sat_string()); + } + _ => return None, + } + Some(source) +} + enum IntersectBodyOutcome { Area(Body), Touching, @@ -551,51 +574,6 @@ impl super::OpenCADStudio { groups } - fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { - let i = self.active_tab; - let Some(document) = - crate::scene::convert::acis_export::solid_to_sat(&result) - else { - self.command_line - .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); - return false; - }; - let Some(EntityType::Solid3D(mut entity)) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { - return false; - }; - entity.wires = solid_model::edge_wires(&result); - entity.silhouettes.clear(); - entity.history_handle = None; - entity.set_sat_document(&document); - - let Some(display) = self.tabs[i].scene.prepare_solid_model_display(handle, &result) - .filter(|display| display.0.complete) - else { - self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); - return false; - }; - self.push_undo_snapshot(i, label); - self.tabs[i].scene.delete_solid_history(handle); - if !self.tabs[i] - .scene - .update_entity(EntityType::Solid3D(entity)) - { - self.command_line - .push_error(crate::t!("The solid could not be updated.").as_ref()); - return false; - } - let history = solid_history::brep_op(&result); - self.tabs[i].scene.create_solid_history(handle, history); - self.tabs[i].scene.register_prepared_solid_model(handle, result, display); - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.select_entity(handle, false); - self.tabs[i].dirty = true; - self.refresh_properties(); - true - } - fn replace_solid_body_with_fillet( &mut self, handle: Handle, @@ -605,17 +583,12 @@ impl super::OpenCADStudio { radius: f64, ) -> bool { let i = self.active_tab; - let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&result) else { - self.command_line - .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); - return false; - }; let Some(display) = self.tabs[i] .scene .prepare_solid_model_display(handle, &result) .filter(|display| display.0.complete) else { - self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref()); return false; }; let ordered = source.edge_keys().collect::>(); @@ -630,7 +603,7 @@ impl super::OpenCADStudio { .collect::>>() else { self.command_line - .push_error(crate::t!("A selected edge no longer belongs to the solid.").as_ref()); + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); return false; }; @@ -641,7 +614,7 @@ impl super::OpenCADStudio { .create_solid_history(handle, solid_history::brep_op(source)) { self.command_line - .push_error(crate::t!("The solid history could not be created.").as_ref()); + .push_error(crate::t!("The edge body history could not be created.").as_ref()); return false; } if !self.tabs[i].scene.append_solid_history( @@ -652,20 +625,103 @@ impl super::OpenCADStudio { .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); return false; } - let Some(EntityType::Solid3D(mut entity)) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return false; }; - entity.wires = solid_model::edge_wires(&result); - entity.silhouettes.clear(); - entity.set_sat_document(&document); - if !self.tabs[i] + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); + return false; + } + self.tabs[i] .scene - .update_entity(EntityType::Solid3D(entity)) + .register_prepared_solid_model(handle, result, display); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + fn replace_edge_body_with_chamfer( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + base_face: FaceKey, + base_distance: f64, + other_distance: f64, + ) -> bool { + let i = self.active_tab; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref()); + return false; + }; + let ordered_edges = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered_edges + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); + return false; + }; + let Some(history_face) = source + .face_keys() + .position(|candidate| candidate == base_face) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + else { + self.command_line + .push_error(crate::t!("The selected base face no longer belongs to the body.").as_ref()); + return false; + }; + + self.push_undo_snapshot(i, "CHAMFEREDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) { self.command_line - .push_error(crate::t!("The solid could not be updated.").as_ref()); + .push_error(crate::t!("The edge body history could not be created.").as_ref()); + return false; + } + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::chamfer_op( + history_edges, + history_face, + base_distance, + other_distance, + ), + ) { + self.command_line + .push_error(crate::t!("The chamfer history could not be recorded.").as_ref()); + return false; + } + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return false; + }; + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); return false; } self.tabs[i] @@ -682,19 +738,20 @@ impl super::OpenCADStudio { &mut self, handle: Handle, edges: &[EdgeKey], + base_face: Option, value: f64, + other_value: f64, fillet: bool, ) -> Task { let i = self.active_tab; if self.reject_locked_edit(i, handle) { return Task::none(); } - if !matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(EntityType::Solid3D(_)) - ) { + if !matches!(self.tabs[i].scene.document.get_entity(handle), Some( + EntityType::Solid3D(_) | EntityType::Surface(_) + )) { self.command_line - .push_error(crate::t!("Select a 3D solid edge.").as_ref()); + .push_error(crate::t!("Select a 3D solid or surface edge.").as_ref()); return Task::none(); } self.tabs[i].scene.restore_solid_models(&[handle]); @@ -713,9 +770,25 @@ impl super::OpenCADStudio { } } } else { - edges - .first() - .and_then(|edge| cadkernel::brep::chamfer(&body, *edge, value)) + let Some(base_face) = base_face else { + self.command_line + .push_error(crate::t!("CHAMFEREDGE: select a base face edge.").as_ref()); + return Task::none(); + }; + match cadkernel::brep::chamfer_edges( + &body, + edges, + base_face, + value, + other_value, + ) { + Ok(result) => Some(result), + Err(error) => { + self.command_line + .push_error(&format!("CHAMFEREDGE: {error}")); + return Task::none(); + } + } }; let Some(result) = result else { self.command_line.push_error( @@ -724,13 +797,25 @@ impl super::OpenCADStudio { ); return Task::none(); }; - let label = if fillet { "FILLETEDGE" } else { "SOLIDCHAMFER" }; + let label = if fillet { "FILLETEDGE" } else { "CHAMFEREDGE" }; let updated = if fillet { self.replace_solid_body_with_fillet(handle, &body, result, edges, value) } else { - self.replace_solid_body(handle, result, label) + self.replace_edge_body_with_chamfer( + handle, + &body, + result, + edges, + base_face.expect("checked chamfer face"), + value, + other_value, + ) }; if updated { + if !fillet { + self.tabs[i].scene.document.header.chamfer_distance_a = value; + self.tabs[i].scene.document.header.chamfer_distance_b = other_value; + } self.command_line .push_output(crate::tf!("{label}: solid updated.").as_ref()); } diff --git a/src/command.rs b/src/command.rs index f91b124b8..c68bfb9f2 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1551,7 +1551,9 @@ pub enum CmdResult { SolidEdgeBlend { handle: Handle, edges: Vec, + base_face: Option, value: f64, + other_value: f64, fillet: bool, }, SolidSubtract { diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index a88e2fc88..ae7cb1303 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use acadrust::Handle; -use cadkernel::brep::{Body, EdgeKey}; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use glam::DVec3; use crate::command::{ @@ -39,7 +39,8 @@ enum EdgeStep { PreviewConfirm, Radius, Expression, - ChamferValue, + ChamferDistance1, + ChamferDistance2, } pub struct SolidEdgeCommand { @@ -48,14 +49,17 @@ pub struct SolidEdgeCommand { bodies: Vec<(Handle, Body)>, handle: Option, selected_edges: Vec, + base_face: Option, selection_batches: Vec<(Vec, DVec3)>, selection_mode: EdgeSelectionMode, step: EdgeStep, - radius_return: EdgeStep, - loop_candidates: Vec>, + value_return: EdgeStep, + expression_return: EdgeStep, + loop_candidates: Vec<(FaceKey, Vec)>, loop_index: usize, last_pick: Option, default_value: f64, + other_value: f64, preview_color: [f32; 4], preview_wires: Vec, preview_hidden: Vec, @@ -74,10 +78,12 @@ impl SolidEdgeCommand { bodies, handle: None, selected_edges: Vec::new(), + base_face: None, selection_batches: Vec::new(), selection_mode: EdgeSelectionMode::Edge, step: EdgeStep::Selecting, - radius_return: EdgeStep::Selecting, + value_return: EdgeStep::Selecting, + expression_return: EdgeStep::Radius, loop_candidates: Vec::new(), loop_index: 0, last_pick: None, @@ -86,12 +92,29 @@ impl SolidEdgeCommand { } else { 1.0 }, + other_value: 1.0, preview_color, preview_wires: Vec::new(), preview_hidden: Vec::new(), } } + pub fn new_chamfer( + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + distances: (f64, f64), + ) -> Self { + let mut command = Self::new(EdgeOperation::Chamfer, target, bodies, preview_color); + command.default_value = positive_default(distances.0); + command.other_value = positive_default(distances.1); + command + } + + pub fn chamfer_distances(&self) -> (f64, f64) { + (self.default_value, self.other_value) + } + fn body(&self, handle: Handle) -> Option<&Body> { self.bodies .iter() @@ -126,7 +149,13 @@ impl SolidEdgeCommand { fn current_loop(&self) -> Option<&[EdgeKey]> { self.loop_candidates .get(self.loop_index) - .map(Vec::as_slice) + .map(|(_, edges)| edges.as_slice()) + } + + fn current_loop_face(&self) -> Option { + self.loop_candidates + .get(self.loop_index) + .map(|(face, _)| *face) } fn preview_edges(&self) -> Vec { @@ -144,17 +173,24 @@ impl SolidEdgeCommand { } fn preview_active(&self) -> bool { - self.operation == EdgeOperation::Fillet - && matches!( - self.step, - EdgeStep::LoopConfirm - | EdgeStep::PreviewConfirm - | EdgeStep::Radius - | EdgeStep::Expression - ) + matches!( + self.step, + EdgeStep::Selecting + | EdgeStep::PickingLoop + | EdgeStep::LoopConfirm + | EdgeStep::PreviewConfirm + | EdgeStep::Radius + | EdgeStep::Expression + | EdgeStep::ChamferDistance1 + | EdgeStep::ChamferDistance2 + ) } - fn preview_for_value(&self, value: f64) -> Option<(Handle, Vec)> { + fn preview_for_values( + &self, + value: f64, + other_value: f64, + ) -> Option<(Handle, Vec)> { if !self.preview_active() { return None; } @@ -163,11 +199,21 @@ impl SolidEdgeCommand { return None; } let (handle, body) = self.active_body()?; - let result = cadkernel::brep::fillet_edges(body, &edges, value).ok()?; + let result = match self.operation { + EdgeOperation::Fillet => cadkernel::brep::fillet_edges(body, &edges, value).ok()?, + EdgeOperation::Chamfer => cadkernel::brep::chamfer_edges( + body, + &edges, + self.base_face.or_else(|| self.current_loop_face())?, + value, + other_value, + ) + .ok()?, + }; let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); for wire in &mut wires { wire.color = self.preview_color; - wire.name = format!("{}-FILLETEDGE-PREVIEW", handle.value()); + wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); } (!wires.is_empty()).then_some((handle, wires)) } @@ -175,14 +221,16 @@ impl SolidEdgeCommand { fn rebuild_preview(&mut self) { self.preview_wires.clear(); self.preview_hidden.clear(); - if let Some((handle, wires)) = self.preview_for_value(self.default_value) { + if let Some((handle, wires)) = + self.preview_for_values(self.default_value, self.other_value) + { self.preview_wires = wires; self.preview_hidden.push(handle); } } fn begin_radius(&mut self, return_to: EdgeStep) -> CmdResult { - self.radius_return = return_to; + self.value_return = return_to; self.step = EdgeStep::Radius; self.rebuild_preview(); CmdResult::NeedPoint @@ -196,7 +244,33 @@ impl SolidEdgeCommand { if self.operation == EdgeOperation::Fillet { set_fillet_radius(value); } - self.step = self.radius_return; + self.step = self.value_return; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn begin_distances(&mut self, return_to: EdgeStep) -> CmdResult { + self.value_return = return_to; + self.step = EdgeStep::ChamferDistance1; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_distance(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + match self.step { + EdgeStep::ChamferDistance1 => { + self.default_value = value; + self.step = EdgeStep::ChamferDistance2; + } + EdgeStep::ChamferDistance2 => { + self.other_value = value; + self.step = self.value_return; + } + _ => return None, + } self.rebuild_preview(); Some(CmdResult::NeedPoint) } @@ -206,6 +280,9 @@ impl SolidEdgeCommand { self.current_loop().map(|edges| edges.to_vec()), self.last_pick, ) { + if self.operation == EdgeOperation::Chamfer && self.base_face.is_none() { + self.base_face = self.current_loop_face(); + } self.add_batch(edges, anchor); } self.loop_candidates.clear(); @@ -225,7 +302,9 @@ impl SolidEdgeCommand { CmdResult::SolidEdgeBlend { handle, edges: self.selected_edges.clone(), + base_face: self.base_face, value: self.default_value, + other_value: self.other_value, fillet: self.operation == EdgeOperation::Fillet, } } @@ -235,20 +314,31 @@ impl CadCommand for SolidEdgeCommand { fn name(&self) -> &'static str { match self.operation { EdgeOperation::Fillet => "FILLETEDGE", - EdgeOperation::Chamfer => "SOLIDCHAMFER", + EdgeOperation::Chamfer => "CHAMFEREDGE", } } fn prompt(&self) -> String { match (self.operation, self.step, self.selection_mode) { (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => { - crate::t!("Select a solid edge:").into_owned() + crate::t!("Select an edge or [Loop/Distance]:").into_owned() } - (EdgeOperation::Chamfer, EdgeStep::ChamferValue, _) => crate::tf!( - "Specify chamfer distance <{:.3}>:", + (EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => { + crate::t!("Select edge of loop or [Edge/Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => { + crate::t!("Press Enter to accept the chamfer or [Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance1, _) => crate::tf!( + "Specify Distance1 or [Expression] <{:.4}>:", self.default_value ) .into_owned(), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance2, _) => crate::tf!( + "Specify Distance2 or [Expression] <{:.4}>:", + self.other_value + ) + .into_owned(), (_, EdgeStep::Selecting, EdgeSelectionMode::Edge) => { crate::t!("Select an edge or [Chain/Loop/Radius]:").into_owned() } @@ -264,7 +354,7 @@ impl CadCommand for SolidEdgeCommand { (_, EdgeStep::PreviewConfirm, _) => { crate::t!("Press Enter to accept the fillet or [Radius]:").into_owned() } - (_, EdgeStep::Radius, _) if self.radius_return == EdgeStep::PreviewConfirm => crate::tf!( + (_, EdgeStep::Radius, _) if self.value_return == EdgeStep::PreviewConfirm => crate::tf!( "Specify Radius or [Expression] <{:.4}>:", self.default_value ) @@ -281,6 +371,26 @@ impl CadCommand for SolidEdgeCommand { fn options(&self) -> Vec { match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => vec![ + CmdOption::new("Loop", "L"), + CmdOption::new("Distance", "D"), + ], + (EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Distance", "D"), + ], + (EdgeOperation::Chamfer, EdgeStep::LoopConfirm, _) => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => { + vec![CmdOption::new("Distance", "D")] + } + ( + EdgeOperation::Chamfer, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2, + _, + ) => vec![CmdOption::new("Expression", "E")], (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Edge) => vec![ CmdOption::new("Chain", "C"), CmdOption::new("Loop", "L"), @@ -338,14 +448,14 @@ impl CadCommand for SolidEdgeCommand { self.handle = Some(handle); self.last_pick = Some(point); - if self.operation == EdgeOperation::Chamfer { - self.add_batch(vec![seed], point); - self.step = EdgeStep::ChamferValue; - return CmdResult::NeedPoint; - } - if self.step == EdgeStep::PickingLoop { self.loop_candidates = edge_loops(self.body(handle).expect("pick body exists"), seed); + if self.operation == EdgeOperation::Chamfer { + if let Some(base_face) = self.base_face { + self.loop_candidates + .retain(|(face, _)| *face == base_face); + } + } self.loop_index = 0; self.step = if self.loop_candidates.is_empty() { EdgeStep::Selecting @@ -356,16 +466,38 @@ impl CadCommand for SolidEdgeCommand { return CmdResult::NeedPoint; } + if self.operation == EdgeOperation::Chamfer { + if self.base_face.is_none() { + self.base_face = self.body(handle).and_then(|body| { + nearest_edge_face(body, seed, point.to_array()) + }); + } + let Some(base_face) = self.base_face else { + return CmdResult::NeedPoint; + }; + if !edge_belongs_to_face(self.body(handle).expect("pick body exists"), seed, base_face) + { + return CmdResult::NeedPoint; + } + self.add_batch(vec![seed], point); + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + let edges = match self.selection_mode { EdgeSelectionMode::Edge => vec![seed], EdgeSelectionMode::Chain => edge_chain(self.body(handle).expect("pick body exists"), seed), }; self.add_batch(edges, point); + self.rebuild_preview(); CmdResult::NeedPoint } fn on_point(&mut self, point: DVec3) -> CmdResult { - if !matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) { + if !matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) { return CmdResult::NeedPoint; } let Some(anchor) = self.last_pick else { @@ -375,26 +507,65 @@ impl CadCommand for SolidEdgeCommand { if value <= 0.0 || !value.is_finite() { return CmdResult::NeedPoint; } - if self.step == EdgeStep::ChamferValue { - self.default_value = value; - self.finish() - } else { - self.accept_radius(value).unwrap_or(CmdResult::NeedPoint) + match self.step { + EdgeStep::Radius => self.accept_radius(value).unwrap_or(CmdResult::NeedPoint), + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => { + self.accept_distance(value).unwrap_or(CmdResult::NeedPoint) + } + _ => CmdResult::NeedPoint, } } fn on_text_input(&mut self, text: &str) -> Option { let keyword = text.trim().to_ascii_uppercase(); if self.operation == EdgeOperation::Chamfer { - if self.step != EdgeStep::ChamferValue { - return None; - } - let value = keyword.parse::().ok()?; - if value <= 0.0 || !value.is_finite() { - return None; - } - self.default_value = value; - return Some(self.finish()); + return match self.step { + EdgeStep::Selecting => match keyword.as_str() { + "L" | "LOOP" => { + self.step = EdgeStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::Selecting)), + _ => None, + }, + EdgeStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PickingLoop)), + _ => None, + }, + EdgeStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PreviewConfirm => match keyword.as_str() { + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PreviewConfirm)), + _ => None, + }, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.expression_return = self.step; + self.step = EdgeStep::Expression; + Some(CmdResult::NeedPoint) + } else { + let value = keyword.parse::().ok()?; + self.accept_distance(value) + } + } + EdgeStep::Expression => { + let value = crate::app::expr_eval::eval_number(text)?; + self.step = self.expression_return; + self.accept_distance(value) + } + EdgeStep::Radius => None, + }; } match self.step { @@ -450,16 +621,29 @@ impl CadCommand for SolidEdgeCommand { self.accept_radius(value) } EdgeStep::Expression => { - let value = keyword.parse::().ok()?; + let value = crate::app::expr_eval::eval_number(text)?; self.accept_radius(value) } - EdgeStep::ChamferValue => None, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => None, } } fn on_enter(&mut self) -> CmdResult { match (self.operation, self.step) { - (EdgeOperation::Chamfer, EdgeStep::ChamferValue) => self.finish(), + (EdgeOperation::Chamfer, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { + self.step = EdgeStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + (EdgeOperation::Chamfer, EdgeStep::LoopConfirm) => self.accept_loop(), + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm) => self.finish(), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance1) => self + .accept_distance(self.default_value) + .unwrap_or(CmdResult::NeedPoint), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance2) => self + .accept_distance(self.other_value) + .unwrap_or(CmdResult::NeedPoint), + (EdgeOperation::Chamfer, EdgeStep::Expression) => CmdResult::NeedPoint, (EdgeOperation::Chamfer, _) => CmdResult::Cancel, (_, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { self.step = EdgeStep::PreviewConfirm; @@ -477,9 +661,6 @@ impl CadCommand for SolidEdgeCommand { } fn on_undo_step(&mut self) -> Option { - if self.operation != EdgeOperation::Fillet { - return None; - } if self.step == EdgeStep::LoopConfirm { self.loop_candidates.clear(); self.loop_index = 0; @@ -495,6 +676,7 @@ impl CadCommand for SolidEdgeCommand { } if self.selected_edges.is_empty() { self.handle = None; + self.base_face = None; } self.last_pick = self .selection_batches @@ -508,17 +690,27 @@ impl CadCommand for SolidEdgeCommand { fn wants_text_input(&self) -> bool { matches!( self.step, - EdgeStep::Radius | EdgeStep::Expression | EdgeStep::ChamferValue + EdgeStep::Radius + | EdgeStep::Expression + | EdgeStep::ChamferDistance1 + | EdgeStep::ChamferDistance2 ) } fn dyn_commit_as_text(&self) -> bool { - matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) } fn dyn_spec(&self) -> Option { let pick = self.last_pick?; - matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue).then(|| DynSpec { + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) + .then(|| DynSpec { anchor: DynAnchor::Point(pick), fields: vec![DynFieldSpec::new(match self.operation { EdgeOperation::Fillet => DynRole::Radius, @@ -530,16 +722,26 @@ impl CadCommand for SolidEdgeCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - matches!(self.step, EdgeStep::Radius | EdgeStep::ChamferValue) + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) .then(|| self.last_pick.map(|pick| cursor.distance(pick))) .flatten() } fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { - if self.step == EdgeStep::Radius { + if matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) { if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) { if value > 0.0 && value.is_finite() { - if let Some((handle, wires)) = self.preview_for_value(value) { + let values = match self.step { + EdgeStep::ChamferDistance2 => (self.default_value, value), + _ => (value, self.other_value), + }; + if let Some((handle, wires)) = self.preview_for_values(values.0, values.1) { self.preview_wires = wires; self.preview_hidden.clear(); self.preview_hidden.push(handle); @@ -558,7 +760,7 @@ impl CadCommand for SolidEdgeCommand { } } -fn edge_loops(body: &Body, seed: EdgeKey) -> Vec> { +fn edge_loops(body: &Body, seed: EdgeKey) -> Vec<(FaceKey, Vec)> { let Some(edge) = body.edges.get(seed) else { return Vec::new(); }; @@ -575,13 +777,48 @@ fn edge_loops(body: &Body, seed: EdgeKey) -> Vec> { .iter() .filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge)) .collect::>(); - if !edges.is_empty() && !candidates.contains(&edges) { - candidates.push(edges); + let candidate = (edge_loop.owner, edges); + if !candidate.1.is_empty() && !candidates.contains(&candidate) { + candidates.push(candidate); } } candidates } +fn edge_belongs_to_face(body: &Body, edge: EdgeKey, face: FaceKey) -> bool { + body.edges.get(edge).is_some_and(|edge| { + edge.coedges.iter().any(|coedge| { + body.coedges + .get(*coedge) + .and_then(|coedge| body.loops.get(coedge.owner)) + .is_some_and(|edge_loop| edge_loop.owner == face) + }) + }) +} + +fn nearest_edge_face(body: &Body, edge: EdgeKey, point: [f64; 3]) -> Option { + let edge = body.edges.get(edge)?; + edge.coedges + .iter() + .filter_map(|coedge| { + let edge_loop = body.loops.get(body.coedges.get(*coedge)?.owner)?; + let face = body.faces.get(edge_loop.owner)?; + let surface = body.surfaces.get(face.surface)?; + let distance = surface.distance_to(point).abs(); + distance.is_finite().then_some((edge_loop.owner, distance)) + }) + .min_by(|first, second| first.1.total_cmp(&second.1)) + .map(|(face, _)| face) +} + +fn positive_default(value: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else { + 1.0 + } +} + fn edge_chain(body: &Body, seed: EdgeKey) -> Vec { let mut chain = vec![seed]; let mut next = 0; @@ -643,5 +880,5 @@ fn edge_tangent_from_vertex( } inventory::submit!(crate::command::CommandRegistration { - names: &["FILLETEDGE", "SOLIDFILLET", "SOLIDCHAMFER"] + names: &["FILLETEDGE", "SOLIDFILLET", "CHAMFEREDGE", "SOLIDCHAMFER"] }); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 72699a0b7..d33b28208 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -89,7 +89,7 @@ impl CadModule for ModelModule { title: "Edges", tools: vec![ RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)), - RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("CHAMFEREDGE", "Chamfer", CHAMFER_ICON)), ], }, ] diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 0e86cd324..650b90b66 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,7 +1,8 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ - DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, + DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryChamfer, + SolidHistoryCone, SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, + SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; @@ -33,6 +34,8 @@ pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000; pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000; pub const GRIP_REVOLVE_AXIS: usize = 10_013; pub const GRIP_FILLET_RADIUS: usize = 10_014; +pub const GRIP_CHAMFER_DISTANCE1: usize = 10_015; +pub const GRIP_CHAMFER_DISTANCE2: usize = 10_016; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -3132,6 +3135,104 @@ pub fn fillet_radius_grip(body: &Body, radius: f64) -> Option { None } +pub fn chamfer_distance_grips( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryChamfer, +) -> Vec { + let Some(operations) = document.solid_history_operations(handle) else { + return Vec::new(); + }; + let Some(chamfer_index) = operations + .iter() + .rposition(|operation| matches!(operation, SolidHistoryOperation::Chamfer(_))) + else { + return Vec::new(); + }; + let Ok(source) = cadkernel::acis::rebuild_history(&operations[..chamfer_index]) else { + return Vec::new(); + }; + let edges = source.edge_keys().collect::>(); + let faces = source.face_keys().collect::>(); + let Some(edge) = value + .edges + .first() + .and_then(|ordinal| usize::try_from(*ordinal).ok()) + .and_then(|ordinal| edges.get(ordinal).copied()) + .and_then(|edge| source.edges.get(edge)) + else { + return Vec::new(); + }; + let Some(base_face) = usize::try_from(value.base_face) + .ok() + .and_then(|ordinal| faces.get(ordinal).copied()) + else { + return Vec::new(); + }; + let adjacent = edge + .coedges + .iter() + .filter_map(|coedge| source.coedges.get(*coedge)) + .filter_map(|coedge| source.loops.get(coedge.owner)) + .map(|edge_loop| edge_loop.owner) + .collect::>(); + let Some(other_face) = adjacent.iter().copied().find(|face| *face != base_face) else { + return Vec::new(); + }; + let outward = |face_key| { + let face = source.faces.get(face_key)?; + let Surface::Plane(plane) = source.surfaces.get(face.surface)? else { + return None; + }; + let normal = glam::DVec3::from_array(plane.normal()?).try_normalize()?; + Some(if face.forward { normal } else { -normal }) + }; + let Some(base_normal) = outward(base_face) else { + return Vec::new(); + }; + let Some(other_normal) = outward(other_face) else { + return Vec::new(); + }; + let dot = base_normal.dot(other_normal); + let Some(base_inward) = (-(other_normal - base_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(other_inward) = (-(base_normal - other_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(start) = source.vertices.get(edge.start) else { + return Vec::new(); + }; + let Some(end) = source.vertices.get(edge.end) else { + return Vec::new(); + }; + let midpoint = + (glam::DVec3::from_array(start.point) + glam::DVec3::from_array(end.point)) * 0.5; + [ + ( + GRIP_CHAMFER_DISTANCE1, + value.base_distance, + base_inward, + ), + ( + GRIP_CHAMFER_DISTANCE2, + value.other_distance, + other_inward, + ), + ] + .into_iter() + .filter(|(_, distance, _)| distance.is_finite() && *distance > 0.0) + .map(|(id, distance, axis)| { + grip( + id, + midpoint + axis * distance, + GripShape::Square, + Some(axis), + ) + }) + .collect() +} + pub fn primitive_grips( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -3151,6 +3252,9 @@ pub fn primitive_grips( .into_iter() .collect(); } + if let SolidHistoryOperation::Chamfer(value) = operation { + return chamfer_distance_grips(document, handle, value); + } let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3782,3 +3886,21 @@ pub fn fillet_op(edges: Vec, radius: f64) -> SolidHistoryOperation { ..SolidHistoryFillet::default() }) } + +pub fn chamfer_op( + edges: Vec, + base_face: i32, + base_distance: f64, + other_distance: f64, +) -> SolidHistoryOperation { + SolidHistoryOperation::Chamfer(SolidHistoryChamfer { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + base_distance, + other_distance, + edges, + base_face, + ..SolidHistoryChamfer::default() + }) +} diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 47dc462ca..da829a765 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1183,6 +1183,47 @@ impl Scene { value.radii.push(radius); return self.preview_solid_history(handle, operation); } + if matches!( + grip_id, + crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + | crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE2 + ) { + let definitions = { + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &operation else { + return false; + }; + crate::scene::model::solid_history::chamfer_distance_grips( + &self.document, + handle, + value, + ) + }; + let Some(definition) = definitions.into_iter().find(|grip| grip.id == grip_id) else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { + return false; + }; + let distance = if grip_id + == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + { + &mut value.base_distance + } else { + &mut value.other_distance + }; + *distance += change; + if !distance.is_finite() || *distance <= 1.0e-6 { + return false; + } + return self.preview_solid_history(handle, operation); + } if !crate::scene::model::solid_history::apply_primitive_grip( &mut operation, grip_id, From 43f97a3bcb5a2d72f3ae219e2a83cd2f2e6bde4d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:06:52 +0300 Subject: [PATCH 17/40] Use analytic chamfer kernel support --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c45a8f9d5..2c9277586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4#2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" +source = "git+https://github.com/ramox81/cadkernel.git?rev=55a09bc3f79fde624e7daf7249ed570675e0aae2#55a09bc3f79fde624e7daf7249ed570675e0aae2" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 1d8f7725b..cf0cfc59f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "2318df5ee52b22a65e38cd4c34ab27ba8bdcf5c4" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "55a09bc3f79fde624e7daf7249ed570675e0aae2" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] From 116c4836a1192764eb8f5dfe92875dccab68efbf Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 14:39:37 +0100 Subject: [PATCH 18/40] fix(render): render selection and hover highlights for analytical circles and ellipses --- src/scene/pipeline/gpu_tests.rs | 81 +++++++++++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 26 ++++++----- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 0660c95e5..7b7ab968f 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -409,6 +409,87 @@ fn late_device_errors_invalidate_uploaded_content_and_targets() { ); } +#[test] +#[ignore = "requires a GPU adapter"] +fn test_selected_circle_arc_ellipse_highlight_overlay() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + + let mut circle_wire = WireModel::default(); + circle_wire.name = "circle_wire".into(); + circle_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 10.0, + }); + + let handle = acadrust::Handle::new(100); + let mut selected_handles = rustc_hash::FxHashSet::default(); + selected_handles.insert(handle); + let hover_handles = rustc_hash::FxHashSet::default(); + + let mut index = rustc_hash::FxHashMap::default(); + index.insert(handle.value(), vec![0]); + pipeline.wire_handle_index = std::sync::Arc::new(index); + + let wires = vec![circle_wire]; + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + Some([0.0, 1.0, 1.0, 1.0]), + ); + + assert_eq!(pipeline.gpu_selected_circles.len(), 1); + assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); + assert!(pipeline.gpu_selected_wires.is_empty()); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + #[test] #[ignore = "requires a GPU adapter"] fn test_pline_arc_switch_preview_and_render() { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index c77fa6d2d..362f045ab 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2658,14 +2658,14 @@ impl Pipeline { } } let mut selected_circles: Vec = Vec::new(); - for &wire in &selected_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { - for mut inst in insts { - if let Some(tint) = selected_tint { + if let Some(tint) = selected_tint { + for &wire in &selected_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + for mut inst in insts { inst.color = tint; + selected_circles.push(inst); } - selected_circles.push(inst); } } } @@ -2690,14 +2690,14 @@ impl Pipeline { }; let mut selected_ellipses: Vec = Vec::new(); - for &wire in &selected_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { - for mut inst in insts { - if let Some(tint) = selected_tint { + if let Some(tint) = selected_tint { + for &wire in &selected_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + for mut inst in insts { inst.color = tint; + selected_ellipses.push(inst); } - selected_ellipses.push(inst); } } } @@ -4779,6 +4779,8 @@ impl Pipeline { || !self.block_text_highlight_gpu.is_empty(); if !self.gpu_selected_wires.is_empty() || !self.gpu_selected_block_wires.is_empty() + || !self.gpu_selected_circles.is_empty() + || !self.gpu_selected_ellipses.is_empty() || have_text_highlight { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { From b4ae4cad4e3a21d5f79e27c81d7b2d03740ec30a Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:25:10 +0300 Subject: [PATCH 19/40] perf: measure an edit from the input to the frame that answers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawing a point in a large drawing feels slow, and nothing currently measures the thing the user actually waits for. `update` times the message handler, `wires-build` and friends time the rebuild, but the span between them — press to the frame that shows the new geometry — has no number. Navigation already has exactly that plumbing: `record_nav_perf` stamps an instant when the input arrives, `prepare` and `encode` pick it up, and `nav-prepare` / `nav-render` report `input`, `build`, `prepare` and `elapsed` from the original event. `NavPerfOp::Edit` reuses it. `update` snapshots `geometry_epoch` before dispatch and stamps the sample when it moved, so any message that changed geometry — a drawn point, a grip commit, an undo — reports its full latency rather than only the handler's share. Diagnostic only: `record_nav_perf` sets a pending sample and nothing else, so no LOD or settle behaviour changes. Nothing is optimised here on purpose. Four call sites feed the snapper along different branches, and picking one to instrument before knowing which dominates is how the earlier rounds of this work were wasted. Co-Authored-By: Claude Opus 5 --- src/app/update/mod.rs | 14 ++++++++++++++ src/scene/mod.rs | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b80f9ee7c..c612c61d0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -265,6 +265,13 @@ impl OpenCADStudio { self.control_observe_user_message(&msg); let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); + // Geometry epoch before dispatch. When it moves, this message drew or + // changed something, and the latency the user feels runs from here to + // the frame that shows it — not to the end of this handler. Stamping + // the nav-perf sample makes `nav-prepare` / `nav-render` report that + // span, the same way they already do for pan and zoom. + let perf_epoch_before = perf_started + .map(|_| self.tabs[self.active_tab].scene.geometry_epoch); // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as @@ -324,6 +331,13 @@ impl OpenCADStudio { self.otrack_active = None; self.otrack_kind = None; } + if let (Some(started), Some(before)) = (perf_started, perf_epoch_before) { + let tab = &self.tabs[self.active_tab]; + if tab.scene.geometry_epoch != before { + tab.scene + .record_nav_perf(crate::scene::NavPerfOp::Edit, started); + } + } if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 5.0 { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 07d219156..4d20b37a3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1432,6 +1432,10 @@ pub(crate) enum NavPerfOp { Pan, Zoom, Rotate, + /// A message that changed geometry. Reported through the same line as + /// navigation because the question is the same one: how long from the + /// input to the frame that answers it. + Edit, } impl NavPerfOp { @@ -1440,6 +1444,7 @@ impl NavPerfOp { Self::Pan => "pan", Self::Zoom => "zoom", Self::Rotate => "rotate", + Self::Edit => "edit", } } } From 6b03e4cf17e5431475db039f3372d506b8efe830 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:07:29 +0300 Subject: [PATCH 20/40] perf: hand the loader's draw-order map to the scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open sequence computes it twice: [perf] draw-depth 293.5ms entries=1166328 ← loader thread … [perf] draw-depth 302.0ms entries=1166328 ← main thread [perf] update 1427.3ms message=other `DrawDepthCache` is keyed on `geometry_epoch`, the loader builds it on its temporary scene, and `PreparedOpenGeometry` carried the wire set, the tessellation memo and the block definitions across — but not this. So the receiving scene walked all 1 166 328 entities again to rebuild a map that already existed, inside the message that finishes opening the file. It travels on exactly the terms the block definitions already do: the document is the loader's own and has not been touched between the loader finishing and the install, so its order labels describe the receiving scene exactly. Only the epoch is restamped. Tested by handing a computed map back into a scene through `install_prepared_open_geometry` and asserting both that it is served as-is and that it equals what that scene recomputes from scratch — the second assertion is the one that matters, since serving a map that does not describe the document would put every entity at the wrong draw order. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 07d219156..a7060ac7e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -437,6 +437,10 @@ pub struct PreparedOpenGeometry { pub tess_guard: u64, /// Loader block definitions; the receiving scene supplies its own epoch. pub block_defns: Vec<(u64, Arc)>, + /// The loader's draw-order map. Computing it walks all 1.17 M entities and + /// costs ~300 ms; the loader already paid that, and the receiving scene + /// used to pay it again on the first frame. + pub(crate) draw_depths: Option, } impl std::fmt::Debug for PreparedOpenGeometry { @@ -557,7 +561,8 @@ fn inserted_draw_depth_label(order: &[DrawDepthEntry], position: usize) -> Optio } } -struct DrawDepthCache { +#[derive(Clone)] +pub(crate) struct DrawDepthCache { epoch: u64, depths: Arc>, /// Per-block stable order labels, including deleted tombstones retained for @@ -1026,6 +1031,7 @@ pub fn prepare_open_geometry( // Taken, not cloned: the scene is discarded on the next line. let tess_memo = std::mem::take(&mut *scene.resident_tess_memo.borrow_mut()); let tess_guard = scene.resident_tess_guard.get(); + let draw_depths = scene.draw_depth_cache.borrow_mut().take(); let block_defns: Vec<(u64, Arc)> = std::mem::take(&mut *scene.block_defn_cache.borrow_mut()) .into_iter() @@ -1040,6 +1046,7 @@ pub fn prepare_open_geometry( tess_memo, tess_guard, block_defns, + draw_depths, }, ) } @@ -2250,6 +2257,15 @@ impl Scene { *self.resident_tess_memo.borrow_mut() = prepared.tess_memo; self.resident_tess_guard.set(prepared.tess_guard); } + // The draw-order map, on the same terms as the block definitions + // below: the document is the loader's own, unmodified since, so its + // order labels describe this scene exactly. Only the epoch is this + // scene's. Without this the first frame recomputed it — a walk of + // every entity in the drawing, ~300 ms, for a map that already existed. + if let Some(mut depths) = prepared.draw_depths { + depths.epoch = self.geometry_epoch; + *self.draw_depth_cache.borrow_mut() = Some(depths); + } // Same for the block definitions, stamped with this scene's block // epoch: the document has not been touched between the loader // finishing and this call, so they describe it exactly. @@ -10543,6 +10559,60 @@ mod journal_tests { assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); } + /// The loader computes the draw-order map and the receiving scene used to + /// compute it again — a walk of every entity, ~300 ms, for a map that + /// already existed. Handing it over is only sound if it describes the + /// receiving scene's document exactly, so that is what this asserts. + #[test] + fn the_handed_over_draw_depth_map_matches_a_recompute() { + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + scene.add_entity(EntityType::Circle(Circle::new())); + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(3.0, 0.0, 0.0), + ))); + + // What the loader would hand over, and what it built it from. + let expected = scene.draw_depth_map(); + let handed_over = scene + .draw_depth_cache + .borrow_mut() + .take() + .expect("the map was just computed, so it is cached"); + + // Installing it stands in for `install_prepared_open_geometry`: the + // epoch is the receiving scene's, the contents are the loader's. + scene.bump_geometry(); + scene.install_prepared_open_geometry(PreparedOpenGeometry { + wires: Arc::new(Vec::new()), + interaction_index: None, + tess_memo: HashMap::default(), + tess_guard: 0, + block_defns: Vec::new(), + draw_depths: Some(handed_over), + }); + assert_eq!( + *scene.draw_depth_map(), + *expected, + "the handed-over map must be served as-is", + ); + + // ...and it must be the map this scene would have produced itself. + *scene.draw_depth_cache.borrow_mut() = None; + assert_eq!( + *scene.draw_depth_map(), + *expected, + "a recompute must agree with what was handed over", + ); + } + #[test] fn entity_index_incremental_matches_full_rebuild() { use acadrust::entities::Line; From d5859a01a5424747056179c88b15124b2bc02037 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:12:19 +0300 Subject: [PATCH 21/40] perf: separate the two costs inside the visible-entity selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first drawn point costs 1068 ms and `wires-build` owns 710 of it: visible=402.5 build=148.4 [classify=18.4 hits=127.5] sort=41.9 memo_hit=186455 memo_miss=1 candidates=444938 The memo is doing its job. `visible` is not, and two things in that loop could own the 402 ms: resolving each handle through the document's hash index, or the predicate — which reaches `document.layers.get(&c.layer)`, a **string-keyed** lookup, so a string hash per entity, 444 938 times. They call for different fixes, so `visible_probe=` times a resolve-only pass and the remainder is the predicate. The pass repeats the resolve rather than reading the clock inside the loop, where the measurement would cost more than the thing measured, and it runs under PERF only. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index a7060ac7e..d5130bc76 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8939,6 +8939,11 @@ impl Scene { // version of this diagnostic report a constant. let visible_path: &str; let mut visible_candidates: usize; + // `visible` costs ~400 ms over 444 937 candidates, and two things in + // the loop could own that: resolving each handle through the document's + // hash index, or the predicate, which looks a layer up **by name** — + // a string hash per entity. Timed separately, under PERF only. + let mut visible_probe_ms = 0.0f64; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -9027,6 +9032,20 @@ impl Scene { } } } + if perf { + // A second, resolve-only pass. It repeats work rather than + // instrumenting inside the loop, where a clock read per + // iteration would cost more than what it measures. + let t_probe = iced::time::Instant::now(); + let mut resolved = 0usize; + for &handle in claimed { + if self.document.get_entity(handle).is_some() { + resolved += 1; + } + } + std::hint::black_box(resolved); + visible_probe_ms = t_probe.elapsed().as_secs_f64() * 1000.0; + } out }; @@ -9271,7 +9290,8 @@ impl Scene { "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ -guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", +guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={} \ +visible_probe={:.1}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, @@ -9298,6 +9318,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", annotation_scale_handle.map(|h| h.value()).unwrap_or(0), all_visible, crate::scene::text::sdf_atlas::generation(), + visible_probe_ms, ); } wires From 41b794e9a769a26ffd80496237b562cab35d26dd Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:27:35 +0300 Subject: [PATCH 22/40] perf: resolve each layer once per selection, not once per entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Table::get` normalises the name with `to_uppercase`, so every layer lookup allocates a String and runs a Unicode pass. `resident_entity_visible` did one per entity, and the selection calls it once per candidate to look up a few hundred distinct layers. The predicate splits so the layer can be passed in, and the selection resolves each distinct name once into a small map keyed on the entity's own layer string. Keys borrow from the document, so naming the lifetime on `wires_for_block_culled` is what lets them outlive the closure call. **Measured, and smaller than it looks.** On a 585 786-entity drawing with 37 826 candidates, A/B through `OCS_NO_LAYER_MEMO`: with memo visible=55.1ms without visible=57.1ms 3.5 %, not the order of magnitude the allocation count suggested. The per-entity cost of that selection is dominated by traversing entities scattered across the document arena, not by the work done at each one — a resolve-only pass over the same handles measures 1.4 ms, and only because it runs second, on a warm cache. Kept because it is measured, cheap and correct, not because it is the answer to the selection's cost. `OCS_NO_LAYER_MEMO` stays so the claim can be rechecked on other drawings. The keying is on the **raw** name while the table is case-insensitive, so two spellings of one layer take two slots and both resolve to it — one extra normalisation, never a different answer. Tested, along with the predicate that reads it. The early cheap checks moved into the layer-taking variant rather than staying in the wrapper, so calling it directly cannot skip them. `vis_index=` reports what `block_members` costs inside `visible`: 19.2 of the 55 ms here, rebuilt by walking the document once per `geometry_epoch` — which moves on every edit. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 146 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 9 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d5130bc76..8e5c0d1df 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8826,6 +8826,32 @@ impl Scene { frozen_layers: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, + ) -> bool { + let layer = self.document.layers.get(&e.common().layer); + self.resident_entity_visible_with_layer( + e, + block_handle, + frozen_layers, + annotation_scale_handle, + all_visible, + layer, + ) + } + + /// `resident_entity_visible` with the layer already resolved. + /// + /// `Table::get` normalises the name with `to_uppercase`, which allocates, + /// and the selection calls this once per candidate — 444 938 allocations on + /// the reproducer, ~440 ms, to look up a few hundred distinct layers. A + /// caller in a loop resolves each name once and passes the result here. + fn resident_entity_visible_with_layer( + &self, + e: &EntityType, + block_handle: Handle, + frozen_layers: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + layer: Option<&acadrust::tables::layer::Layer>, ) -> bool { let c = e.common(); if c.invisible { @@ -8839,7 +8865,6 @@ impl Scene { if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) { return false; } - let layer = self.document.layers.get(&c.layer); if layer .map(|l| l.flags.off || l.flags.frozen) .unwrap_or(false) @@ -8866,8 +8891,8 @@ impl Scene { self.belongs_to_visible_block(c.handle, c.owner_handle, block_handle) } - fn wires_for_block_culled( - &self, + fn wires_for_block_culled<'doc>( + &'doc self, block_handle: Handle, view_aabb: Option<[f32; 4]>, wpp: Option, @@ -8921,13 +8946,45 @@ impl Scene { // Visibility test reused by both paths below — and by the resident // incremental patch, so a changed entity is included/excluded exactly as // a from-scratch build would (no divergence). - let visibility_ok = |e: &EntityType| { - self.resident_entity_visible( + // `Table::get` normalises with `to_uppercase`, so every layer lookup + // allocates. The selection asks once per candidate and a drawing has a + // few hundred layers, so the name is resolved once and remembered. + // Keys borrow the entity's own layer string, which lives in the + // document for the whole call. + type LayerRef<'a> = Option<&'a acadrust::tables::layer::Layer>; + let layer_of: RefCell>> = + RefCell::new(rustc_hash::FxHashMap::default()); + // Kill switch, so one binary can measure both paths on one drawing. + // Same shape as `OCS_NO_RESIDENT_MEMO`. + fn layer_memo_enabled() -> bool { + static EN: std::sync::OnceLock = std::sync::OnceLock::new(); + *EN.get_or_init(|| std::env::var("OCS_NO_LAYER_MEMO").is_err()) + } + let memo_on = layer_memo_enabled(); + let visibility_ok = |e: &'doc EntityType| { + let name = e.common().layer.as_str(); + if !memo_on { + return self.resident_entity_visible( + e, + block_handle, + frozen_layers, + annotation_scale_handle, + all_visible, + ); + } + let layer = match layer_of.borrow_mut().entry(name) { + std::collections::hash_map::Entry::Occupied(hit) => *hit.get(), + std::collections::hash_map::Entry::Vacant(slot) => { + *slot.insert(self.document.layers.get(name)) + } + }; + self.resident_entity_visible_with_layer( e, block_handle, frozen_layers, annotation_scale_handle, all_visible, + layer, ) }; @@ -8943,6 +9000,7 @@ impl Scene { // the loop could own that: resolving each handle through the document's // hash index, or the predicate, which looks a layer up **by name** — // a string hash per entity. Timed separately, under PERF only. + let mut visible_index_ms = 0.0f64; let mut visible_probe_ms = 0.0f64; // Phase 2.1 — quadtree-driven candidate selection. When a view @@ -9020,7 +9078,13 @@ impl Scene { // block's list is already in document order and the wires come out // in exactly the order the full scan produced. visible_path = "block-index"; + // The index itself: rebuilt by walking the whole document once per + // `geometry_epoch`, and the epoch moves on every edit. Timed + // separately because it is inside `visible` and is not per-entity + // work, so no per-candidate breakdown can ever account for it. + let t_members = perf.then(iced::time::Instant::now); let members = self.block_members(); + visible_index_ms = crate::perf::elapsed_ms(t_members); let (_, by_block) = &*members; let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); visible_candidates = claimed.len(); @@ -9033,9 +9097,13 @@ impl Scene { } } if perf { - // A second, resolve-only pass. It repeats work rather than - // instrumenting inside the loop, where a clock read per - // iteration would cost more than what it measures. + // Resolve-only pass, for the share of `visible` that is the + // document's hash index rather than the predicate. Repeated + // rather than clocked per iteration, and PERF only. + // + // It runs after the real loop, so it reads a warm cache and + // undercounts what the first traversal actually paid. Treat it + // as a floor, not as the cost. let t_probe = iced::time::Instant::now(); let mut resolved = 0usize; for &handle in claimed { @@ -9291,7 +9359,7 @@ impl Scene { build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={} \ -visible_probe={:.1}", +vis_index={:.1} visible_probe={:.1}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, @@ -9318,6 +9386,7 @@ visible_probe={:.1}", annotation_scale_handle.map(|h| h.value()).unwrap_or(0), all_visible, crate::scene::text::sdf_atlas::generation(), + visible_index_ms, visible_probe_ms, ); } @@ -10580,6 +10649,65 @@ mod journal_tests { assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); } + /// The layer memo keys on the entity's **raw** layer string, while + /// `Table::get` normalises with `to_uppercase`. Two spellings of one layer + /// therefore take two memo slots, and both must resolve to that same layer + /// — otherwise the memo would hide geometry the unmemoized path shows. + /// + /// Covers the property the memo rests on (the table is case-insensitive) + /// and the predicate that reads it, not the memo's own bookkeeping. + #[test] + fn the_layer_memo_respects_case_insensitive_layer_names() { + use acadrust::entities::Line; + use acadrust::tables::layer::Layer; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let mut hidden = Layer::new("Walls"); + hidden.flags.off = true; + scene.document.layers.add_or_replace(hidden); + + let mut on_layer = |layer: &str| { + let mut line = Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ); + line.common.layer = layer.to_string(); + scene.add_entity(EntityType::Line(line)) + }; + // Three spellings of the same, switched-off layer, plus one that is + // genuinely a different layer and stays visible. + on_layer("Walls"); + on_layer("WALLS"); + on_layer("walls"); + on_layer("Doors"); + + let block = scene.model_space_block_handle(); + let visible: Vec<_> = scene + .document + .entities() + .filter(|e| scene.resident_entity_visible(e, block, None, None, true)) + .map(|e| e.common().layer.clone()) + .collect(); + assert_eq!( + visible, + vec!["Doors".to_string()], + "every spelling of a switched-off layer must be hidden", + ); + + // The memo takes one slot per spelling; each must find the same layer. + let resolved: Vec<_> = ["Walls", "WALLS", "walls"] + .iter() + .map(|name| scene.document.layers.get(name).map(|l| l.name.clone())) + .collect(); + assert_eq!( + resolved, + vec![Some("Walls".to_string()); 3], + "the table is case-insensitive, which is what lets the memo key on \ + the raw name", + ); + } + /// The loader computes the draw-order map and the receiving scene used to /// compute it again — a walk of every entity, ~300 ms, for a map that /// already existed. Handing it over is only sound if it describes the From d267083a298ad13b67b67d0c4204f40393626a8a Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:34:12 +0300 Subject: [PATCH 23/40] perf: say which message caused an edit's latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first measurement showed four consecutive edits at ~192 ms each with a handler cost of 0.55 ms. That is below the `update` line's 5 ms threshold, so the log reports the latency but not what asked for it — and whether a live command preview is bumping the geometry epoch on every mouse move is exactly the question the numbers raise. `nav-prepare` now carries the message label alongside the op. Co-Authored-By: Claude Opus 5 --- src/app/update/mod.rs | 7 +++++-- src/scene/mod.rs | 14 ++++++++++++++ src/scene/view/render.rs | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c612c61d0..fe8519aa1 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -334,8 +334,11 @@ impl OpenCADStudio { if let (Some(started), Some(before)) = (perf_started, perf_epoch_before) { let tab = &self.tabs[self.active_tab]; if tab.scene.geometry_epoch != before { - tab.scene - .record_nav_perf(crate::scene::NavPerfOp::Edit, started); + tab.scene.record_nav_perf_caused( + crate::scene::NavPerfOp::Edit, + perf_label, + started, + ); } } if let Some(started) = perf_started { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4d20b37a3..e19534afa 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1452,6 +1452,10 @@ impl NavPerfOp { #[derive(Clone, Copy, Debug)] pub(in crate::scene) struct NavPerfSample { pub(in crate::scene) op: NavPerfOp, + /// Which message caused it. An edit whose handler runs in under a + /// millisecond never reaches the `update` line's threshold, so without this + /// the log shows the latency but not what asked for it. + pub(in crate::scene) cause: &'static str, pub(in crate::scene) space: &'static str, pub(in crate::scene) mode: &'static str, pub(in crate::scene) started: iced::time::Instant, @@ -2880,6 +2884,15 @@ impl Scene { } pub(crate) fn record_nav_perf(&self, op: NavPerfOp, started: iced::time::Instant) { + self.record_nav_perf_caused(op, "-", started); + } + + pub(crate) fn record_nav_perf_caused( + &self, + op: NavPerfOp, + cause: &'static str, + started: iced::time::Instant, + ) { if !crate::perf::enabled() { return; } @@ -2892,6 +2905,7 @@ impl Scene { }; self.nav_perf_pending.set(Some(NavPerfSample { op, + cause, space, mode, started, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index b25fb4810..93c8047de 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1216,8 +1216,9 @@ impl shader::Primitive for Primitive { let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0; if let Some(sample) = self.nav_perf { crate::perf::record(format_args!( - "[perf] nav-prepare op={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}", + "[perf] nav-prepare op={} cause={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}", sample.op.label(), + sample.cause, sample.space, sample.mode, sample.input_ms, From 5e5a810ee5d9da207b3de851c5acfe9f2c8b748c Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:43:22 +0300 Subject: [PATCH 24/40] perf: stop rebuilding the selection filter list on every drawn point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `view-detail` on an edit frame: total=105.6ms chrome=105.4 [every other section 0.0] and the edit latency measurement puts ~102 ms of a 192 ms drawn point in the gap between the handler returning and the scene primitive being built — which is that same `chrome`. The status bar asks for `entity_type_names_in_layout` on every frame to populate the selection-filter menu, and the cache behind it was keyed on `geometry_epoch`. Every drawn point bumps that epoch, so every drawn point walked all 444 937 handles a model space owns to answer a question the line did not change: adding a line to a drawing that already has lines leaves the set of type names exactly as it was. The cache now carries the set alongside the list, and folds edits in from the delta journal — the same incremental shape `entity_index` already uses. Additions can only grow the set, so they are applied directly and the existing `Arc` is handed back untouched when nothing new appeared. A removal can retire a type and cannot be resolved from a handle that is already gone, so those still fall back to the full walk. `entity_type_name` returns a literal for every arm but one — `Extended` borrows its class name from the entity — so the set holds owned strings, and the hot path tests `contains` before allocating. Tested: a second line of an existing type must hand back the same `Arc`, a new type must still appear, and the folded answer must equal what a rebuild from scratch produces. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 15 +++++- src/scene/selection.rs | 119 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 07d219156..f2c5423cb 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1851,7 +1851,20 @@ pub struct Scene { /// (geometry_epoch, layout block). The status bar asks for this on every /// frame to populate the selection-filter menu, but the answer only /// changes when entities are added or removed. - layout_type_names_cache: RefCell>)>>, + /// `(epoch, block, present type names, the list handed to the UI)`. + /// + /// The set is kept alongside the list so an edit can be folded in without + /// rebuilding either: drawing a line into a drawing that already has lines + /// does not change the answer, and the walk that produces it visits every + /// entity the layout owns. + layout_type_names_cache: RefCell< + Option<( + u64, + Handle, + std::collections::BTreeSet, + std::sync::Arc>, + )>, + >, /// Reverse dependencies from layer/style/block definitions to the top-level /// entities whose resident wire runs actually change. Kept independent from /// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 590635f04..de9248c31 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -361,18 +361,71 @@ impl Scene { } /// Sorted entity types in the current layout, cached until geometry or layout changes. + /// The entity type names present in the current layout, for the selection + /// filter menu. + /// + /// The status bar asks for this on **every** frame, and the cache used to + /// be keyed on `geometry_epoch` alone — so every drawn point invalidated it + /// and the walk below visited all 444 937 handles a model space owns. That + /// was ~100 ms per edit, the largest single cost of drawing a line, and + /// almost always to arrive at the same answer: adding a line to a drawing + /// that already has lines changes nothing here. + /// + /// An edit is now folded in from the delta journal instead. Additions only + /// ever grow the set, so they are cheap to apply; a removal can retire a + /// type and cannot be resolved from a handle that is already gone, so those + /// fall back to the full walk. pub fn entity_type_names_in_layout(&self) -> std::sync::Arc> { use crate::entities::traits::entity_type_name; let block = self.current_layout_block_handle(); + let mut cached_epoch = None; { let cache = self.layout_type_names_cache.borrow(); - if let Some((epoch, cached_block, names)) = cache.as_ref() { - if *epoch == self.geometry_epoch && *cached_block == block { - return std::sync::Arc::clone(names); + if let Some((epoch, cached_block, _, names)) = cache.as_ref() { + if *cached_block == block { + if *epoch == self.geometry_epoch { + return std::sync::Arc::clone(names); + } + cached_epoch = Some(*epoch); } } } - let mut names: std::collections::BTreeSet<&str> = + + // Incremental: fold the changes since the cached epoch into the set. + if let Some(since) = cached_epoch { + if let Some(deltas) = self.replay_since(since) { + if !deltas.iter().any(|(_, kind)| *kind == ChangeKind::Removed) { + let mut cache = self.layout_type_names_cache.borrow_mut(); + if let Some((epoch, _, present, names)) = cache.as_mut() { + let mut added = false; + for (handle, _) in &deltas { + if let Some(entity) = self.document.get_entity(*handle) { + if entity.common().owner_handle == block { + // `contains` first: the common case is a + // type already present, and that path must + // not allocate. + let name = entity_type_name(entity); + if !present.contains(name) { + present.insert(name.to_string()); + added = true; + } + } + } + } + // Only rebuild the list when the set actually moved; + // otherwise the existing `Arc` is still the answer. + if added { + *names = + std::sync::Arc::new(present.iter().cloned().collect()); + } + *epoch = self.geometry_epoch; + return std::sync::Arc::clone(names); + } + } + } + } + + let mut present: std::collections::BTreeSet = std::collections::BTreeSet::new(); if let Some(record) = self .document @@ -382,15 +435,21 @@ impl Scene { { for &handle in &record.entity_handles { if let Some(entity) = self.document.get_entity(handle) { - names.insert(entity_type_name(entity)); + let name = entity_type_name(entity); + if !present.contains(name) { + present.insert(name.to_string()); + } } } } - let names: std::sync::Arc> = std::sync::Arc::new( - names.into_iter().map(str::to_string).collect(), - ); - *self.layout_type_names_cache.borrow_mut() = - Some((self.geometry_epoch, block, std::sync::Arc::clone(&names))); + let names: std::sync::Arc> = + std::sync::Arc::new(present.iter().cloned().collect()); + *self.layout_type_names_cache.borrow_mut() = Some(( + self.geometry_epoch, + block, + present, + std::sync::Arc::clone(&names), + )); names } @@ -954,6 +1013,46 @@ impl Scene { mod tests { use super::*; + /// The reason the cache carries a set as well as a list: drawing into a + /// drawing that already has that type must not rebuild either, because the + /// rebuild walks every entity the layout owns — 444 937 of them on the + /// reproducer, ~100 ms, on every single drawn point. + #[test] + fn adding_a_type_already_present_reuses_the_list() { + use acadrust::entities::{Circle, EntityType, Line}; + use acadrust::types::Vector3; + use std::sync::Arc; + + let line = || { + EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + )) + }; + let mut scene = Scene::new(); + scene.add_entity(line()); + let first = scene.entity_type_names_in_layout(); + + scene.add_entity(line()); + let second = scene.entity_type_names_in_layout(); + assert!( + Arc::ptr_eq(&first, &second), + "a second line changes no type name, so the list must be reused", + ); + + scene.add_entity(EntityType::Circle(Circle::new())); + let third = scene.entity_type_names_in_layout(); + assert_eq!(third.as_slice(), ["Circle", "Line"], "a new type must appear"); + + // The incremental answer has to be the answer a full walk gives. + scene.layout_type_names_cache.borrow_mut().take(); + assert_eq!( + scene.entity_type_names_in_layout().as_slice(), + third.as_slice(), + "folding edits in must match rebuilding from scratch", + ); + } + #[test] fn layout_type_cache_reuses_and_invalidates_on_edits_undo_and_layout() { use acadrust::entities::{Circle, EntityType, Line}; From 6f01d4215bc5404444cf2978248283de24fcb8e1 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:18:17 +0300 Subject: [PATCH 25/40] perf: fold a lone addition into the block-member index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vis_index=19.4` of a 55 ms selection, on every build, because the index is keyed on `geometry_epoch` and the epoch moves on every edit. The walk visits every entity in the drawing to add one handle to one list. A lone addition is folded in instead. **Only** a lone one: entities are appended to the document, so a single new handle belongs at the end of its block's list, whereas `replay_since` returns several changes in no particular order and each list must stay in document order because that is draw order. A removal, a modification that could move an entity between blocks, or more than one change all take the full walk. Measured on a 586 k-entity drawing, headless, opening then drawing two lines with a rebuild after each: before visible=50.7 vis_index=19.7 visible=48.2 vis_index=17.6 after visible=34.1 vis_index=0.0 visible=34.4 vis_index=0.0 A third of the selection, and it holds for each subsequent edit. Tested against the full walk, including that the appended handle lands last — putting it anywhere else would reorder the drawing. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 8e5c0d1df..187b0d7a5 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9415,14 +9415,59 @@ vis_index={:.1} visible_probe={:.1}", } /// Resolve owner handles and block-record membership once per geometry epoch. + /// + /// The walk visits every entity in the drawing and the epoch moves on every + /// edit, so it used to run again for each one — 19 ms of a 55 ms selection + /// on a 586 k-entity drawing, to add a single handle to a single list. + /// + /// A lone addition is folded in instead. Only a lone one: entities are + /// appended to the document, so a single new handle belongs at the end of + /// its block's list, while `replay_since` returns several changes in no + /// particular order and each list is in document order because that is + /// draw order. Anything else — a removal, a modification that could move an + /// entity between blocks, more than one change — takes the full walk. fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { + let mut cached_epoch = None; { let cache = self.block_members_cache.borrow(); - if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { - drop(cache); - return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { - c.as_ref().unwrap() + if let Some((epoch, _)) = cache.as_ref() { + if *epoch == self.geometry_epoch { + drop(cache); + return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } + cached_epoch = Some(*epoch); + } + } + + if let Some(since) = cached_epoch { + let lone_add = self.replay_since(since).filter(|deltas| { + deltas.len() == 1 && deltas[0].1 == ChangeKind::Added + }); + if let Some(deltas) = lone_add { + let handle = deltas[0].0; + // Resolve the owner before touching the cache: both lookups + // borrow, and `entity_block_map` is its own `RefCell`. + let owner = self.document.get_entity(handle).and_then(|entity| { + let owner = entity.common().owner_handle; + if !owner.is_null() { + Some(owner) + } else { + self.entity_block_map().get(&handle).copied() + } }); + let mut cache = self.block_members_cache.borrow_mut(); + if let Some((epoch, members)) = cache.as_mut() { + if let Some(owner) = owner { + members.entry(owner).or_default().push(handle); + } + *epoch = self.geometry_epoch; + drop(cache); + return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } } } let mut members: HashMap> = HashMap::default(); @@ -10597,6 +10642,42 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. + #[test] + /// Folding a lone addition must land the handle where the full walk would + /// have put it — at the end of its block's list, because that list is in + /// document order and document order is draw order. + #[test] + fn folding_one_addition_matches_the_full_scan() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + fn add(scene: &mut Scene, x: f64) -> Handle { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + } + add(&mut scene, 0.0); + add(&mut scene, 1.0); + // Build the index, then add one more so the next call folds. + let block = scene.model_space_block_handle(); + let before = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let third = add(&mut scene, 2.0); + let folded = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + + scene.block_members_cache.borrow_mut().take(); + let rebuilt = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + + assert_eq!(folded, rebuilt, "the folded index must equal a full rebuild"); + assert_eq!( + folded.last(), + Some(&third), + "an appended entity belongs at the end of its block's list", + ); + assert_eq!(folded.len(), before.len() + 1); + } + #[test] fn block_member_index_matches_the_full_scan() { use acadrust::entities::Line; From feb3ca4d28c753c89c5a83d2b3fb5f5f4cac1350 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:58:33 +0300 Subject: [PATCH 26/40] perf: split the arena patch's post-work into its three steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the selection-filter rebuild gone, a drawn point costs 98 ms and `prepare` is 93 % of it: wire 73.0ms arena-patch wires=236961 gpu_instances=405551 wire-cull 5.1ms face3d-upload 3.9ms text-upload 3.0ms resident-patch 1.0ms changes=1 draw-depth-patch 0.0ms `arena-base ok=true patch=Some(16) changes=1` — the arena patches incrementally, one entity, and still takes 73 ms. The patch itself is careful; the three steps that follow it are not, and all three are O(resident set) rather than O(changes): partition_wires(&vp_wires, …) 236 961 wires, every frame upload_block_wires(…instanced…) 10 719 placements rebuilt upload_circles/ellipses_from_instances `arena-post partition= blocks= curves=` says which dominates. Guessing between three plausible candidates is what the earlier rounds of this work paid for repeatedly. Co-Authored-By: Claude Opus 5 --- src/scene/view/render.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index b25fb4810..faf1a165b 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -839,8 +839,15 @@ impl shader::Primitive for Primitive { gpus.extend(arena.wire_gpus()); } inner.gpu_wires = std::sync::Arc::new(gpus); + // A one-entity patch costs 73 ms in this block, and the + // three steps below are all O(resident set) rather than + // O(changes). Which of them dominates decides the fix. + let t_part = _perf.then(iced::time::Instant::now); let partitioned = wire_arena::partition_wires(&vp_wires, &draw_depths); + let part_ms = t_part + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_blk = _perf.then(iced::time::Instant::now); inner.gpu_block_wires = std::sync::Arc::new( inner.upload_block_wires( device, @@ -850,6 +857,9 @@ impl shader::Primitive for Primitive { &mut pipeline.block_geometry, ), ); + let blk_ms = t_blk + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_curve = _perf.then(iced::time::Instant::now); inner.gpu_circles = std::sync::Arc::new( inner.upload_circles_from_instances( device, @@ -864,6 +874,17 @@ impl shader::Primitive for Primitive { &partitioned.ellipse_instances, ), ); + if _perf { + crate::perf_record!( + "[perf] arena-post partition={part_ms:.1}ms blocks={blk_ms:.1}ms \ +curves={:.1}ms wires={} instanced={} circles={} ellipses={}", + t_curve.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0), + vp_wires.len(), + partitioned.instanced.len(), + partitioned.circle_instances.len(), + partitioned.ellipse_instances.len(), + ); + } if _patched { wire_arena::patch_handle_index( &mut inner.wire_handle_index, From b68989768daa9db1610f109d5423c89f6bae76b8 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:08:35 +0300 Subject: [PATCH 27/40] perf: look the draw depth up only where it is used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is the whole of the 73 ms an arena patch spends after patching, and it runs on every drawn point over the entire resident set. Inside it, `wire_draw_depth` is a hash lookup whose result feeds only the analytical circle and ellipse extraction, and it was computed before the test that decides whether either will run. So every patch paid 236 956 lookups to serve the ~56 000 wires that are circles or ellipses. Moving it inside that branch is behaviour-preserving: the value was unused on every path that skipped it. This does not make the partition incremental — it still walks the resident set for a one-entity change — but that walk is memory-bound and a structural fix needs the cached partition to survive a patch, which the borrows in `PartitionedWires` make its own piece of work. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/wire_arena.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index bbaddb773..954e2fd8e 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -199,12 +199,16 @@ pub fn partition_wires<'a>( instanced.push(wire); continue; } - let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); + // `wire_draw_depth` is a hash lookup and its result is used only by the + // analytical extractions below, which most wires never reach. Computing + // it first cost one lookup per resident wire on every patch — 236 956 + // of them for the ~56 000 that are circles or ellipses. if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() && wire.pick_tris.is_empty() && wire.text_verts.is_empty() { + let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); if let Some(insts) = super::circle_gpu::extract_circle_instances(wire, depth) { circle_instances.extend(insts); continue; From 03c64e3d383e37075c405071485ff03cb80b569b Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:34:25 +0300 Subject: [PATCH 28/40] perf: skip the wire partition when a patch cannot change its output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is almost the whole of the 73 ms an arena patch spends after patching, and it walks all 236 956 resident wires to serve a one-entity change. Its three consumers here — the instanced, circle and ellipse uploads — depend only on wires that are block references or analytical curves. A patch that touches none of those cannot change any of them, so the walk is skipped and the uploads already on the slot are kept. Two things make that sound, and both are checked rather than assumed: **Depths hold still.** The instances bake a draw depth. `inserted_draw_depth_label` allocates into a reserved gap instead of renumbering, so adding an entity leaves every other label alone — the existing doc comment says so and there is now a test. A rebuild from scratch *can* move them, so `draw_depth_generation` bumps only there, and a slot reuses its uploads only while that generation is unchanged. **Contributors are remembered.** `partition_wires` records which handles fed those three outputs, and the skip requires that no changed handle is among them and that no changed handle's new wires would join them. That catches an entity entering the set and an entity leaving it. The membership test is shared with the walk it decides to skip, so the two cannot drift, and it is deliberately conservative: a wire that looks analytical but fails extraction forces a needless re-partition, never a stale upload. `arena-post` gains `skipped=` so a run says how often this actually fires rather than leaving it to be inferred from the timing. Not verified on a device yet — the headless harness never reaches `prepare`, so this needs a GUI run on the reproducer to confirm both the saving and that nothing goes missing on screen. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 63 +++++++++++++++++++ src/scene/pipeline/mod.rs | 13 ++++ src/scene/pipeline/wire_arena.rs | 31 ++++++++++ src/scene/view/render.rs | 102 +++++++++++++++++++++---------- 4 files changed, 176 insertions(+), 33 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 187b0d7a5..d6a269690 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1706,6 +1706,12 @@ pub struct Scene { /// constants. `[depth, half]` retains a fixed child sub-range for block /// composition. Full sort/layout/block changes rebuild the labels. draw_depth_cache: RefCell>, + /// Bumped only when the draw-order map is rebuilt from scratch, which is + /// the one case that can change an existing entity's depth — an + /// incremental insert allocates into the reserved label gap and leaves + /// every other label alone. Consumers that cached anything carrying a baked + /// depth compare this before reusing it. + draw_depth_generation: std::cell::Cell, /// Shared empty map for 3-D wireframe, where true depth wins instead of /// the entity submission order used by the optimized 2-D style. no_draw_depths: Arc>, @@ -2053,6 +2059,7 @@ impl Scene { interaction_handle_index_cache: RefCell::new(None), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), + draw_depth_generation: std::cell::Cell::new(0), no_draw_depths: Arc::new(HashMap::default()), hatch_cache: RefCell::new(HashMap::default()), wipeout_cache: RefCell::new(HashMap::default()), @@ -5998,6 +6005,11 @@ impl Scene { /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain /// their depth, avoiding an O(all entities) map rewrite and GPU const upload. + /// See [`Self::draw_depth_generation`]. + pub(in crate::scene) fn draw_depth_generation(&self) -> u64 { + self.draw_depth_generation.get() + } + pub(super) fn draw_depth_map(&self) -> Arc> { { let cache = self.draw_depth_cache.borrow(); @@ -6127,6 +6139,11 @@ impl Scene { } } + // From here the labels are assigned afresh, so an existing entity's + // depth can move. Anything holding a baked depth has to rebuild. + self.draw_depth_generation + .set(self.draw_depth_generation.get().wrapping_add(1)); + use acadrust::objects::ObjectType; // Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val). let mut overrides: HashMap> = HashMap::default(); @@ -10643,6 +10660,52 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. #[test] + /// The partition skip reuses uploads that bake a draw depth, and it is only + /// sound while existing depths hold still. An incremental add must leave + /// them alone and must not bump the generation; a rebuild from scratch may + /// move them and must bump it. + #[test] + fn adding_an_entity_leaves_existing_draw_depths_alone() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + fn add(scene: &mut Scene, x: f64) -> Handle { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + } + let mut scene = Scene::new(); + let first = add(&mut scene, 0.0); + let second = add(&mut scene, 1.0); + let before = scene.draw_depth_map(); + let generation = scene.draw_depth_generation(); + + add(&mut scene, 2.0); + let after = scene.draw_depth_map(); + assert_eq!( + scene.draw_depth_generation(), + generation, + "an incremental add must not invalidate baked depths", + ); + for handle in [first, second] { + assert_eq!( + after.get(&handle.value()), + before.get(&handle.value()), + "an existing entity's depth must not move when another is added", + ); + } + + // A rebuild from scratch reassigns labels, so it has to say so. + scene.draw_depth_cache.borrow_mut().take(); + let _ = scene.draw_depth_map(); + assert_ne!( + scene.draw_depth_generation(), + generation, + "a full rebuild must invalidate anything holding a baked depth", + ); + } + /// Folding a lone addition must land the handle where the full walk would /// have put it — at the end of its block's list, because that list is in /// document order and document order is draw order. diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index c77fa6d2d..3c7bcff3c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -251,6 +251,13 @@ pub struct Pipeline { pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet, /// The Model content id both arenas currently mirror (`u64::MAX` = none). pub(crate) wire_arena_id: u64, + /// Entities that fed the instanced / circle / ellipse uploads this slot is + /// holding. A patch touching none of them cannot change those uploads, so + /// the partition walk over the whole resident set can be skipped. + pub(crate) partition_contributors: rustc_hash::FxHashSet, + /// The draw-depth generation those uploads baked. A full depth rebuild + /// reassigns every label, so they stop being reusable when it moves. + pub(crate) partition_depth_generation: u64, /// Last content/camera/viewport tuple used to derive visible instance /// ranges from the resident arena. pub(crate) wire_cull_key: (u64, u64, u32, u32), @@ -2338,6 +2345,8 @@ impl Pipeline { wire_arena_fallback_kind: None, wire_arena_fallback_handles: rustc_hash::FxHashSet::default(), wire_arena_id: u64::MAX, + partition_contributors: rustc_hash::FxHashSet::default(), + partition_depth_generation: u64::MAX, wire_cull_key: (u64::MAX, u64::MAX, 0, 0), hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false), wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false), @@ -4991,6 +5000,8 @@ impl Pipeline { self.wire_arena = None; self.wire_arena_mesh = None; self.wire_arena_id = u64::MAX; + self.partition_contributors.clear(); + self.partition_depth_generation = u64::MAX; self.alloc_size = Size::new(0, 0); self.shadow_full = None; self.background_source_id = usize::MAX; @@ -5022,6 +5033,8 @@ impl Pipeline { self.wire_arena_fallback_kind = None; self.wire_arena_fallback_handles.clear(); self.wire_arena_id = u64::MAX; + self.partition_contributors.clear(); + self.partition_depth_generation = u64::MAX; self.gpu_selected_wires.clear(); self.gpu_selected_block_wires.clear(); diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 954e2fd8e..7dcd29954 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -174,6 +174,27 @@ pub struct PartitionedWires<'a> { pub instanced: Vec<&'a WireModel>, pub circle_instances: Vec, pub ellipse_instances: Vec, + /// Entities that fed `instanced` / `circle_instances` / `ellipse_instances`. + /// + /// A patch that touches none of these cannot change those three uploads, so + /// the caller can keep the ones it already has and skip the walk entirely. + pub contributors: rustc_hash::FxHashSet, +} + +/// Whether a change to this wire can alter what `partition_wires` produces for +/// the instanced, circle and ellipse uploads. +/// +/// Deliberately conservative: a wire that looks analytical here may still fail +/// extraction and end up in `regular`, which costs an unnecessary re-partition +/// and never a stale upload. Shared with `partition_wires` so the test that +/// decides to skip cannot drift from the walk it is skipping. +pub fn feeds_analytical_uploads(wire: &WireModel) -> bool { + wire.display_visible + && (wire.render_instance.is_some() + || (!wire.tangent_geoms.is_empty() + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty())) } /// Single-pass classification and extraction of all viewport wire categories. @@ -190,12 +211,19 @@ pub fn partition_wires<'a>( let mut instanced = Vec::new(); let mut circle_instances = Vec::new(); let mut ellipse_instances = Vec::new(); + let mut contributors: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); + let note = |wire: &WireModel, set: &mut rustc_hash::FxHashSet| { + if let Some(handle) = handle_of(wire) { + set.insert(handle); + } + }; for wire in wires { if !wire.display_visible { continue; } if wire.render_instance.is_some() { + note(wire, &mut contributors); instanced.push(wire); continue; } @@ -210,10 +238,12 @@ pub fn partition_wires<'a>( { let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); if let Some(insts) = super::circle_gpu::extract_circle_instances(wire, depth) { + note(wire, &mut contributors); circle_instances.extend(insts); continue; } if let Some(insts) = super::ellipse_gpu::extract_ellipse_instances(wire, depth) { + note(wire, &mut contributors); ellipse_instances.extend(insts); continue; } @@ -233,6 +263,7 @@ pub fn partition_wires<'a>( instanced, circle_instances, ellipse_instances, + contributors, } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index faf1a165b..cb01fac4d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -149,6 +149,9 @@ pub struct ViewportData { /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no /// depth field (84 construction sites); the bias is looked up by handle /// at GPU-upload time from this map instead. + /// See `Scene::draw_depth_generation`. Cached uploads that bake a depth are + /// only reusable while this is unchanged. + pub(in crate::scene) draw_depth_generation: u64, pub(in crate::scene) draw_depths: std::sync::Weak>, pub(in crate::scene) hatches: Arc>, @@ -842,47 +845,79 @@ impl shader::Primitive for Primitive { // A one-entity patch costs 73 ms in this block, and the // three steps below are all O(resident set) rather than // O(changes). Which of them dominates decides the fix. + // The partition walks the whole resident set, and on + // a patch it almost always produces the three uploads it + // produced last frame. Skip it when no changed entity + // feeds them and none did before. + // + // Safe because adding an entity does not disturb any + // other entity's draw depth: `inserted_draw_depth_label` + // allocates into the reserved label gap rather than + // renumbering, so instances already uploaded stay right. + let analytical_untouched = _patched + && inner.partition_depth_generation == vp.draw_depth_generation + && patch.is_some_and(|patch| { + patch.changes.iter().all(|(handle, _)| { + !inner.partition_contributors.contains(handle) + && !patch.runs.get(handle).is_some_and(|run| { + run.iter() + .any(wire_arena::feeds_analytical_uploads) + }) + }) + }); let t_part = _perf.then(iced::time::Instant::now); - let partitioned = - wire_arena::partition_wires(&vp_wires, &draw_depths); + let partitioned = (!analytical_untouched) + .then(|| wire_arena::partition_wires(&vp_wires, &draw_depths)); let part_ms = t_part .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_blk = _perf.then(iced::time::Instant::now); - inner.gpu_block_wires = std::sync::Arc::new( - inner.upload_block_wires( - device, - queue, - &partitioned.instanced, - &draw_depths, - &mut pipeline.block_geometry, - ), - ); - let blk_ms = t_blk - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); - let t_curve = _perf.then(iced::time::Instant::now); - inner.gpu_circles = std::sync::Arc::new( - inner.upload_circles_from_instances( - device, - queue, - &partitioned.circle_instances, - ), - ); - inner.gpu_ellipses = std::sync::Arc::new( - inner.upload_ellipses_from_instances( - device, - queue, - &partitioned.ellipse_instances, - ), - ); + let mut blk_ms = 0.0f64; + let mut curve_ms = 0.0f64; + // `None` means the three uploads this slot holds are + // still the answer, so they are left alone. + if let Some(partitioned) = &partitioned { + inner.gpu_block_wires = std::sync::Arc::new( + inner.upload_block_wires( + device, + queue, + &partitioned.instanced, + &draw_depths, + &mut pipeline.block_geometry, + ), + ); + blk_ms = t_blk + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_curve = _perf.then(iced::time::Instant::now); + inner.gpu_circles = std::sync::Arc::new( + inner.upload_circles_from_instances( + device, + queue, + &partitioned.circle_instances, + ), + ); + inner.gpu_ellipses = std::sync::Arc::new( + inner.upload_ellipses_from_instances( + device, + queue, + &partitioned.ellipse_instances, + ), + ); + inner + .partition_contributors + .clone_from(&partitioned.contributors); + inner.partition_depth_generation = vp.draw_depth_generation; + curve_ms = t_curve + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + } if _perf { crate::perf_record!( "[perf] arena-post partition={part_ms:.1}ms blocks={blk_ms:.1}ms \ -curves={:.1}ms wires={} instanced={} circles={} ellipses={}", - t_curve.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0), +curves={curve_ms:.1}ms skipped={} wires={} instanced={} circles={} ellipses={}", + partitioned.is_none(), vp_wires.len(), - partitioned.instanced.len(), - partitioned.circle_instances.len(), - partitioned.ellipse_instances.len(), + partitioned.as_ref().map_or(0, |p| p.instanced.len()), + partitioned.as_ref().map_or(0, |p| p.circle_instances.len()), + partitioned.as_ref().map_or(0, |p| p.ellipse_instances.len()), ); } if _patched { @@ -4259,6 +4294,7 @@ impl Scene { face3d_wires, text_verts, preview_text_verts, + draw_depth_generation: self.draw_depth_generation(), draw_depths: Arc::downgrade(&draw_depths), hatches, wipeout_hatches, From d46c00281f85263422b24d0f12c016db8d12aa5c Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:45:16 +0300 Subject: [PATCH 29/40] fix: decide partition membership by extraction, not by shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skipped=false` on every frame of a real session — the skip added in the previous commit never fired once. The membership test used the same cheap structural filter that `partition_wires` uses before attempting extraction: a tangent geom, no fill, pick or text triangles. That filter is a pre-check, not a decision. An ordinary drawn line carries a tangent geom for snapping and no triangles, so it passed, and every drawn line was counted as feeding the analytical uploads. Nothing was ever skippable. It now decides the way the walk decides, by attempting the extraction. `draw_depth` only rides along in the extracted instance, so the value passed for the test cannot change the answer, and it runs only for the handful of wires a patch changed. Drawing a circle or an ellipse still forces the walk, correctly — those are contributors, and the session that exposed this contained both. What changes is that the lines drawn afterwards no longer do. Tested on real tessellated geometry: a line must not force the walk, a circle must. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 38 ++++++++++++++++++++++++++++++++ src/scene/pipeline/wire_arena.rs | 26 +++++++++++++--------- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d6a269690..0fc58caa2 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -10660,6 +10660,44 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. #[test] + /// The partition skip turns on a membership test, and the test has to tell + /// a drawn line from a drawn circle. A line carries a tangent geom for + /// snapping and no triangles, so the cheap structural filter admits it — + /// deciding on that alone called every line a contributor and the skip + /// never fired once in a real session. + #[test] + fn the_partition_membership_test_separates_lines_from_curves() { + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; + + let mut scene = Scene::new(); + let feeds = |scene: &Scene, handle: Handle| { + let entity = scene.document.get_entity(handle).unwrap().clone(); + scene + .tessellate_one(&entity) + .iter() + .any(feeds_analytical_uploads) + }; + + let line = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ))); + assert!( + !feeds(&scene, line), + "a drawn line must not force the partition walk", + ); + + let mut circle = Circle::new(); + circle.radius = 4.0; + let circle = scene.add_entity(EntityType::Circle(circle)); + assert!( + feeds(&scene, circle), + "a drawn circle feeds the analytical uploads and must force the walk", + ); + } + /// The partition skip reuses uploads that bake a draw depth, and it is only /// sound while existing depths hold still. An incremental add must leave /// them alone and must not bump the generation; a rebuild from scratch may diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 7dcd29954..322dea9f0 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -184,17 +184,23 @@ pub struct PartitionedWires<'a> { /// Whether a change to this wire can alter what `partition_wires` produces for /// the instanced, circle and ellipse uploads. /// -/// Deliberately conservative: a wire that looks analytical here may still fail -/// extraction and end up in `regular`, which costs an unnecessary re-partition -/// and never a stale upload. Shared with `partition_wires` so the test that -/// decides to skip cannot drift from the walk it is skipping. +/// Decided by attempting the extraction, exactly as the walk decides it. The +/// cheap structural test alone is not enough: an ordinary drawn line carries a +/// tangent geom for snapping and no triangles, so it passes that test, fails +/// extraction, and lands in `regular` — a membership test that stopped there +/// would call every line a contributor and never skip anything. +/// +/// `draw_depth` only rides along in the extracted instance, so the value passed +/// here cannot change the answer. pub fn feeds_analytical_uploads(wire: &WireModel) -> bool { - wire.display_visible - && (wire.render_instance.is_some() - || (!wire.tangent_geoms.is_empty() - && wire.fill_tris.is_empty() - && wire.pick_tris.is_empty() - && wire.text_verts.is_empty())) + if !wire.display_visible { + return false; + } + if wire.render_instance.is_some() { + return true; + } + super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() + || super::ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_some() } /// Single-pass classification and extraction of all viewport wire categories. From 5eb762e88dcfa88325b8d6b7fe80647780e442e1 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:51:23 +0300 Subject: [PATCH 30/40] fix: forget the partition record with the keys that describe it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `forget_cached_keys` is the one place that means "this slot holds nothing you can trust", and it left the partition record behind. A reused slot rebuilds regardless — its content id changed, so the arena takes the build path and the record is overwritten — but that made the safety an accident of another code path rather than something this function guarantees. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 3c7bcff3c..0c3c5f1ef 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -4987,6 +4987,12 @@ impl Pipeline { self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX); self.render_sig = u64::MAX; + // The partition record describes uploads this slot is no longer + // entitled to reuse. A reused slot rebuilds anyway, because its content + // id changed, but leaving a stale record here would make that an + // accident rather than a guarantee. + self.partition_contributors.clear(); + self.partition_depth_generation = u64::MAX; } /// Catch errors delivered after the previous upload/submit completed. From eef007d08038b84b48117952cf4a39ad9b80d67f Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:37:09 +0300 Subject: [PATCH 31/40] perf: report a skipped partition without printing zero counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip branch printed `circles=0 ellipses=0`, which reads as "there are no circles" when it means "not recomputed this frame" — and those counts were the evidence that confirmed the skip works, so the wording nearly cost a misreading of it. The skip now prints its own line with what it retained. Measured effect of the skip itself, on the reproducer: a drawn point goes from 98 ms to 17-39 ms, `prepare` from 89 ms to 11-23 ms. Frames that draw a circle still re-partition, and the instance counts show it — 40868, 40869 … 40874, one per circle, with the line frames skipped in between. Co-Authored-By: Claude Opus 5 --- src/scene/view/render.rs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index cb01fac4d..8dced63d5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -910,15 +910,27 @@ impl shader::Primitive for Primitive { .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); } if _perf { - crate::perf_record!( - "[perf] arena-post partition={part_ms:.1}ms blocks={blk_ms:.1}ms \ -curves={curve_ms:.1}ms skipped={} wires={} instanced={} circles={} ellipses={}", - partitioned.is_none(), - vp_wires.len(), - partitioned.as_ref().map_or(0, |p| p.instanced.len()), - partitioned.as_ref().map_or(0, |p| p.circle_instances.len()), - partitioned.as_ref().map_or(0, |p| p.ellipse_instances.len()), - ); + // Counts only when a partition produced them. + // Printing zeros on a skip reads as "no circles" + // rather than "not recomputed", which is the + // opposite of what the line is reporting. + match &partitioned { + Some(partitioned) => crate::perf_record!( + "[perf] arena-post partition={part_ms:.1}ms \ +blocks={blk_ms:.1}ms curves={curve_ms:.1}ms skipped=false wires={} instanced={} \ +circles={} ellipses={}", + vp_wires.len(), + partitioned.instanced.len(), + partitioned.circle_instances.len(), + partitioned.ellipse_instances.len(), + ), + None => crate::perf_record!( + "[perf] arena-post skipped=true wires={} \ +retained_contributors={}", + vp_wires.len(), + inner.partition_contributors.len(), + ), + } } if _patched { wire_arena::patch_handle_index( From 261ab66afdc87df7449d9660e2be816aee62b6e2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:11:23 +0300 Subject: [PATCH 32/40] Restore primitive properties after edge operations --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- src/app/model_ops.rs | 10 +++++++++ src/scene/model/solid_history.rs | 19 +++++++++++++--- src/scene/modify.rs | 37 +++++++++++++++++++++++++++----- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c9277586..08c01a023 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/ramox81/cadcodec.git?rev=af1f31287d4730f228cbb27d6884078295627ca7#af1f31287d4730f228cbb27d6884078295627ca7" +source = "git+https://github.com/ramox81/cadcodec.git?rev=5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc#5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=55a09bc3f79fde624e7daf7249ed570675e0aae2#55a09bc3f79fde624e7daf7249ed570675e0aae2" +source = "git+https://github.com/ramox81/cadkernel.git?rev=9d872e04e1b69654e98ce6184ca3efd28a5eb830#9d872e04e1b69654e98ce6184ca3efd28a5eb830" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index cf0cfc59f..24c30c314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,10 +74,10 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "55a09bc3f79fde624e7daf7249ed570675e0aae2" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "9d872e04e1b69654e98ce6184ca3efd28a5eb830" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 17c39b765..7792c9f90 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -625,6 +625,11 @@ impl super::OpenCADStudio { .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); return false; } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return false; }; @@ -713,6 +718,11 @@ impl super::OpenCADStudio { .push_error(crate::t!("The chamfer history could not be recorded.").as_ref()); return false; } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return false; }; diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 650b90b66..58a5a6e3d 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -201,7 +201,7 @@ pub fn has_specialized_primitive_properties( handle: acadrust::Handle, ) -> bool { matches!( - document.solid_history_operation(handle), + primitive_property_operation(document, handle).as_ref(), Some( SolidHistoryOperation::Box(_) | SolidHistoryOperation::Wedge(_) @@ -218,6 +218,19 @@ pub fn has_specialized_primitive_properties( ) } +/// Return the primitive that owns the public Geometry rows. Later operations +/// such as edge fillets and chamfers refine that primitive but do not replace +/// its editable type, position, or dimensions. +pub fn primitive_property_operation( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Option { + document + .solid_history_operations(handle) + .and_then(|operations| operations.into_iter().next()) + .or_else(|| document.solid_history_operation(handle).cloned()) +} + /// Solid history results whose public Properties palette is fully described by /// the common entity rows plus [`primitive_properties`]. A generic B-rep has /// no stable primitive dimensions to expose, but it still uses the compact @@ -1442,10 +1455,10 @@ pub fn primitive_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> Vec { - let Some(operation) = document.solid_history_operation(handle) else { + let Some(operation) = primitive_property_operation(document, handle) else { return brep_properties(document, handle); }; - match operation { + match &operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index da829a765..50f839d34 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -828,8 +828,13 @@ impl Scene { pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document - .solid_history_operation(handle) - .and_then(crate::scene::model::solid_history::reference_point); + .solid_history_operations(handle) + .and_then(|operations| { + operations + .iter() + .rev() + .find_map(crate::scene::model::solid_history::reference_point) + }); let Some(reference) = reference else { return; }; @@ -883,7 +888,24 @@ impl Scene { } (EntityType::Solid3D(_), _) => { let mut operations = self.document.solid_history_operations(handle)?; - *operations.last_mut()? = operation.clone(); + let replacement_id = operation.base().map(|base| { + if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + } + })?; + let target = operations.iter_mut().find(|candidate| { + candidate.base().is_some_and(|base| { + let node_id = if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + }; + node_id == replacement_id + }) + })?; + *target = operation.clone(); cadkernel::acis::rebuild_history(&operations).ok() } _ => None, @@ -938,7 +960,7 @@ impl Scene { self.record_solid_history_before(handle); if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -1240,7 +1262,12 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { + let Some(mut operation) = + crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) + else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property( From 4972bbed705d5e8b52cd24f0deaafdd2ca984419 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 17:33:02 +0300 Subject: [PATCH 33/40] Fix issues found while integrating performance PRs --- src/app/update/mod.rs | 29 +++--- src/scene/mod.rs | 149 ++++++------------------------- src/scene/pipeline/mod.rs | 16 +--- src/scene/pipeline/wire_arena.rs | 34 ++----- src/scene/selection.rs | 44 ++++----- src/scene/view/render.rs | 19 +--- 6 files changed, 78 insertions(+), 213 deletions(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index fe8519aa1..21e117a8d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -265,13 +265,11 @@ impl OpenCADStudio { self.control_observe_user_message(&msg); let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); - // Geometry epoch before dispatch. When it moves, this message drew or - // changed something, and the latency the user feels runs from here to - // the frame that shows it — not to the end of this handler. Stamping - // the nav-perf sample makes `nav-prepare` / `nav-render` report that - // span, the same way they already do for pan and zoom. - let perf_epoch_before = perf_started - .map(|_| self.tabs[self.active_tab].scene.geometry_epoch); + // Keep the stable tab id because dispatch may switch or close tabs. + let perf_edit_before = perf_started.map(|started| { + let tab = &self.tabs[self.active_tab]; + (started, tab.id, tab.scene.geometry_epoch) + }); // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as @@ -331,14 +329,15 @@ impl OpenCADStudio { self.otrack_active = None; self.otrack_kind = None; } - if let (Some(started), Some(before)) = (perf_started, perf_epoch_before) { - let tab = &self.tabs[self.active_tab]; - if tab.scene.geometry_epoch != before { - tab.scene.record_nav_perf_caused( - crate::scene::NavPerfOp::Edit, - perf_label, - started, - ); + if let Some((started, tab_id, before)) = perf_edit_before { + if let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) { + if tab.scene.geometry_epoch != before { + tab.scene.record_nav_perf_caused( + crate::scene::NavPerfOp::Edit, + perf_label, + started, + ); + } } } if let Some(started) = perf_started { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9d62dd7f6..75bc719a5 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -437,9 +437,7 @@ pub struct PreparedOpenGeometry { pub tess_guard: u64, /// Loader block definitions; the receiving scene supplies its own epoch. pub block_defns: Vec<(u64, Arc)>, - /// The loader's draw-order map. Computing it walks all 1.17 M entities and - /// costs ~300 ms; the loader already paid that, and the receiving scene - /// used to pay it again on the first frame. + /// Loader-computed draw-order map, re-stamped by the receiving scene. pub(crate) draw_depths: Option, } @@ -1439,9 +1437,7 @@ pub(crate) enum NavPerfOp { Pan, Zoom, Rotate, - /// A message that changed geometry. Reported through the same line as - /// navigation because the question is the same one: how long from the - /// input to the frame that answers it. + /// A message that changed geometry before the next rendered frame. Edit, } @@ -1459,9 +1455,7 @@ impl NavPerfOp { #[derive(Clone, Copy, Debug)] pub(in crate::scene) struct NavPerfSample { pub(in crate::scene) op: NavPerfOp, - /// Which message caused it. An edit whose handler runs in under a - /// millisecond never reaches the `update` line's threshold, so without this - /// the log shows the latency but not what asked for it. + /// Message label that caused the sample. pub(in crate::scene) cause: &'static str, pub(in crate::scene) space: &'static str, pub(in crate::scene) mode: &'static str, @@ -1715,11 +1709,8 @@ pub struct Scene { /// constants. `[depth, half]` retains a fixed child sub-range for block /// composition. Full sort/layout/block changes rebuild the labels. draw_depth_cache: RefCell>, - /// Bumped only when the draw-order map is rebuilt from scratch, which is - /// the one case that can change an existing entity's depth — an - /// incremental insert allocates into the reserved label gap and leaves - /// every other label alone. Consumers that cached anything carrying a baked - /// depth compare this before reusing it. + /// Bumped when a full draw-order rebuild can move existing depths. + /// Cached uploads compare it before reusing baked depth values. draw_depth_generation: std::cell::Cell, /// Shared empty map for 3-D wireframe, where true depth wins instead of /// the entity submission order used by the optimized 2-D style. @@ -1869,16 +1860,8 @@ pub struct Scene { block_members_cache: RefCell>, /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. unindexable_cache: RefCell)>>, - /// Distinct entity type names present in a layout, keyed by - /// (geometry_epoch, layout block). The status bar asks for this on every - /// frame to populate the selection-filter menu, but the answer only - /// changes when entities are added or removed. - /// `(epoch, block, present type names, the list handed to the UI)`. - /// - /// The set is kept alongside the list so an edit can be folded in without - /// rebuilding either: drawing a line into a drawing that already has lines - /// does not change the answer, and the walk that produces it visits every - /// entity the layout owns. + /// `(epoch, layout block, present type names, list exposed to the UI)`. + /// The set lets pure additions update without rebuilding the list. layout_type_names_cache: RefCell< Option<( u64, @@ -2286,11 +2269,7 @@ impl Scene { *self.resident_tess_memo.borrow_mut() = prepared.tess_memo; self.resident_tess_guard.set(prepared.tess_guard); } - // The draw-order map, on the same terms as the block definitions - // below: the document is the loader's own, unmodified since, so its - // order labels describe this scene exactly. Only the epoch is this - // scene's. Without this the first frame recomputed it — a walk of - // every entity in the drawing, ~300 ms, for a map that already existed. + // Reuse the loader's draw-order map with this scene's epoch. if let Some(mut depths) = prepared.draw_depths { depths.epoch = self.geometry_epoch; *self.draw_depth_cache.borrow_mut() = Some(depths); @@ -8887,12 +8866,7 @@ impl Scene { ) } - /// `resident_entity_visible` with the layer already resolved. - /// - /// `Table::get` normalises the name with `to_uppercase`, which allocates, - /// and the selection calls this once per candidate — 444 938 allocations on - /// the reproducer, ~440 ms, to look up a few hundred distinct layers. A - /// caller in a loop resolves each name once and passes the result here. + /// Visibility predicate with the layer resolved by the caller. fn resident_entity_visible_with_layer( &self, e: &EntityType, @@ -8995,32 +8969,12 @@ impl Scene { // Visibility test reused by both paths below — and by the resident // incremental patch, so a changed entity is included/excluded exactly as // a from-scratch build would (no divergence). - // `Table::get` normalises with `to_uppercase`, so every layer lookup - // allocates. The selection asks once per candidate and a drawing has a - // few hundred layers, so the name is resolved once and remembered. - // Keys borrow the entity's own layer string, which lives in the - // document for the whole call. + // Resolve each distinct layer name once for this candidate pass. type LayerRef<'a> = Option<&'a acadrust::tables::layer::Layer>; let layer_of: RefCell>> = RefCell::new(rustc_hash::FxHashMap::default()); - // Kill switch, so one binary can measure both paths on one drawing. - // Same shape as `OCS_NO_RESIDENT_MEMO`. - fn layer_memo_enabled() -> bool { - static EN: std::sync::OnceLock = std::sync::OnceLock::new(); - *EN.get_or_init(|| std::env::var("OCS_NO_LAYER_MEMO").is_err()) - } - let memo_on = layer_memo_enabled(); let visibility_ok = |e: &'doc EntityType| { let name = e.common().layer.as_str(); - if !memo_on { - return self.resident_entity_visible( - e, - block_handle, - frozen_layers, - annotation_scale_handle, - all_visible, - ); - } let layer = match layer_of.borrow_mut().entry(name) { std::collections::hash_map::Entry::Occupied(hit) => *hit.get(), std::collections::hash_map::Entry::Vacant(slot) => { @@ -9045,10 +8999,7 @@ impl Scene { // version of this diagnostic report a constant. let visible_path: &str; let mut visible_candidates: usize; - // `visible` costs ~400 ms over 444 937 candidates, and two things in - // the loop could own that: resolving each handle through the document's - // hash index, or the predicate, which looks a layer up **by name** — - // a string hash per entity. Timed separately, under PERF only. + // PERF-only breakdown for index maintenance and handle resolution. let mut visible_index_ms = 0.0f64; let mut visible_probe_ms = 0.0f64; @@ -9118,19 +9069,9 @@ impl Scene { .filter(|e| visibility_ok(e)) .collect() } else { - // Ask the per-epoch membership index for this block's candidates - // instead of testing every entity in the document. For a paper - // sheet holding 43 entities in a 1.17 M-entity drawing that is the - // difference between 313 ms and nothing measurable. - // - // The index is filled by walking `document.entities()`, so each - // block's list is already in document order and the wires come out - // in exactly the order the full scan produced. + // The per-epoch index preserves document order for each block. visible_path = "block-index"; - // The index itself: rebuilt by walking the whole document once per - // `geometry_epoch`, and the epoch moves on every edit. Timed - // separately because it is inside `visible` and is not per-entity - // work, so no per-candidate breakdown can ever account for it. + // Time index maintenance separately from the candidate loop. let t_members = perf.then(iced::time::Instant::now); let members = self.block_members(); visible_index_ms = crate::perf::elapsed_ms(t_members); @@ -9146,13 +9087,7 @@ impl Scene { } } if perf { - // Resolve-only pass, for the share of `visible` that is the - // document's hash index rather than the predicate. Repeated - // rather than clocked per iteration, and PERF only. - // - // It runs after the real loop, so it reads a warm cache and - // undercounts what the first traversal actually paid. Treat it - // as a floor, not as the cost. + // PERF-only lower bound for warm handle resolution. let t_probe = iced::time::Instant::now(); let mut resolved = 0usize; for &handle in claimed { @@ -9463,18 +9398,8 @@ vis_index={:.1} visible_probe={:.1}", std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) } - /// Resolve owner handles and block-record membership once per geometry epoch. - /// - /// The walk visits every entity in the drawing and the epoch moves on every - /// edit, so it used to run again for each one — 19 ms of a 55 ms selection - /// on a 586 k-entity drawing, to add a single handle to a single list. - /// - /// A lone addition is folded in instead. Only a lone one: entities are - /// appended to the document, so a single new handle belongs at the end of - /// its block's list, while `replay_since` returns several changes in no - /// particular order and each list is in document order because that is - /// draw order. Anything else — a removal, a modification that could move an - /// entity between blocks, more than one change — takes the full walk. + /// Resolve block membership once per geometry epoch. A single addition is + /// appended safely; any other delta rebuilds the document-ordered index. fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { let mut cached_epoch = None; { @@ -10691,12 +10616,7 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. - #[test] - /// The partition skip turns on a membership test, and the test has to tell - /// a drawn line from a drawn circle. A line carries a tangent geom for - /// snapping and no triangles, so the cheap structural filter admits it — - /// deciding on that alone called every line a contributor and the skip - /// never fired once in a real session. + /// A line has tangent geometry but does not feed the analytical uploads. #[test] fn the_partition_membership_test_separates_lines_from_curves() { use acadrust::entities::{Circle, Line}; @@ -10730,10 +10650,7 @@ mod journal_tests { ); } - /// The partition skip reuses uploads that bake a draw depth, and it is only - /// sound while existing depths hold still. An incremental add must leave - /// them alone and must not bump the generation; a rebuild from scratch may - /// move them and must bump it. + /// Incremental additions preserve existing depths; full rebuilds invalidate them. #[test] fn adding_an_entity_leaves_existing_draw_depths_alone() { use acadrust::entities::Line; @@ -10776,9 +10693,7 @@ mod journal_tests { ); } - /// Folding a lone addition must land the handle where the full walk would - /// have put it — at the end of its block's list, because that list is in - /// document order and document order is draw order. + /// A folded addition must match the full document-order scan. #[test] fn folding_one_addition_matches_the_full_scan() { use acadrust::entities::Line; @@ -10863,13 +10778,8 @@ mod journal_tests { assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); } - /// The layer memo keys on the entity's **raw** layer string, while - /// `Table::get` normalises with `to_uppercase`. Two spellings of one layer - /// therefore take two memo slots, and both must resolve to that same layer - /// — otherwise the memo would hide geometry the unmemoized path shows. - /// - /// Covers the property the memo rests on (the table is case-insensitive) - /// and the predicate that reads it, not the memo's own bookkeeping. + /// Raw layer spellings used as memo keys must resolve through the + /// case-insensitive table. #[test] fn the_layer_memo_respects_case_insensitive_layer_names() { use acadrust::entities::Line; @@ -10889,8 +10799,7 @@ mod journal_tests { line.common.layer = layer.to_string(); scene.add_entity(EntityType::Line(line)) }; - // Three spellings of the same, switched-off layer, plus one that is - // genuinely a different layer and stays visible. + // Spellings of the switched-off layer must all remain hidden. on_layer("Walls"); on_layer("WALLS"); on_layer("walls"); @@ -10909,7 +10818,7 @@ mod journal_tests { "every spelling of a switched-off layer must be hidden", ); - // The memo takes one slot per spelling; each must find the same layer. + // Each spelling resolves to the same table entry. let resolved: Vec<_> = ["Walls", "WALLS", "walls"] .iter() .map(|name| scene.document.layers.get(name).map(|l| l.name.clone())) @@ -10922,10 +10831,7 @@ mod journal_tests { ); } - /// The loader computes the draw-order map and the receiving scene used to - /// compute it again — a walk of every entity, ~300 ms, for a map that - /// already existed. Handing it over is only sound if it describes the - /// receiving scene's document exactly, so that is what this asserts. + /// The loader's handed-over draw depths must equal a local rebuild. #[test] fn the_handed_over_draw_depth_map_matches_a_recompute() { use acadrust::entities::{Circle, Line}; @@ -10942,7 +10848,7 @@ mod journal_tests { Vector3::new(3.0, 0.0, 0.0), ))); - // What the loader would hand over, and what it built it from. + // Capture the map the loader would hand over. let expected = scene.draw_depth_map(); let handed_over = scene .draw_depth_cache @@ -10950,8 +10856,7 @@ mod journal_tests { .take() .expect("the map was just computed, so it is cached"); - // Installing it stands in for `install_prepared_open_geometry`: the - // epoch is the receiving scene's, the contents are the loader's. + // Install it under the receiving scene's epoch. scene.bump_geometry(); scene.install_prepared_open_geometry(PreparedOpenGeometry { wires: Arc::new(Vec::new()), @@ -10967,7 +10872,7 @@ mod journal_tests { "the handed-over map must be served as-is", ); - // ...and it must be the map this scene would have produced itself. + // A local rebuild must agree. *scene.draw_depth_cache.borrow_mut() = None; assert_eq!( *scene.draw_depth_map(), diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 9b3b45b99..74313aa61 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -251,9 +251,7 @@ pub struct Pipeline { pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet, /// The Model content id both arenas currently mirror (`u64::MAX` = none). pub(crate) wire_arena_id: u64, - /// Entities that fed the instanced / circle / ellipse uploads this slot is - /// holding. A patch touching none of them cannot change those uploads, so - /// the partition walk over the whole resident set can be skipped. + /// Handles that contributed to this slot's retained analytical uploads. pub(crate) partition_contributors: rustc_hash::FxHashSet, /// The draw-depth generation those uploads baked. A full depth rebuild /// reassigns every label, so they stop being reusable when it moves. @@ -4961,12 +4959,7 @@ impl Pipeline { } } - /// Forget every cache key that describes this slot's uploaded content. - /// - /// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two - /// cannot disagree about what "this slot holds nothing you can trust" - /// means. Keys only — the buffers themselves are dropped by the caller that - /// wants the memory back. + /// Reset every cache key that describes this slot's uploaded content. pub(crate) fn forget_cached_keys(&mut self) { self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX); self.cached_wire_id = u64::MAX; @@ -4989,10 +4982,7 @@ impl Pipeline { self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX); self.render_sig = u64::MAX; - // The partition record describes uploads this slot is no longer - // entitled to reuse. A reused slot rebuilds anyway, because its content - // id changed, but leaving a stale record here would make that an - // accident rather than a guarantee. + // The retained-upload record is part of the slot's cached state. self.partition_contributors.clear(); self.partition_depth_generation = u64::MAX; } diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 322dea9f0..265ef257d 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -174,33 +174,16 @@ pub struct PartitionedWires<'a> { pub instanced: Vec<&'a WireModel>, pub circle_instances: Vec, pub ellipse_instances: Vec, - /// Entities that fed `instanced` / `circle_instances` / `ellipse_instances`. - /// - /// A patch that touches none of these cannot change those three uploads, so - /// the caller can keep the ones it already has and skip the walk entirely. + /// Handles that contributed to the retained analytical uploads. pub contributors: rustc_hash::FxHashSet, } -/// Whether a change to this wire can alter what `partition_wires` produces for -/// the instanced, circle and ellipse uploads. -/// -/// Decided by attempting the extraction, exactly as the walk decides it. The -/// cheap structural test alone is not enough: an ordinary drawn line carries a -/// tangent geom for snapping and no triangles, so it passes that test, fails -/// extraction, and lands in `regular` — a membership test that stopped there -/// would call every line a contributor and never skip anything. -/// -/// `draw_depth` only rides along in the extracted instance, so the value passed -/// here cannot change the answer. +/// Whether this wire contributes to an upload retained across arena patches. pub fn feeds_analytical_uploads(wire: &WireModel) -> bool { - if !wire.display_visible { - return false; - } - if wire.render_instance.is_some() { - return true; - } - super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() - || super::ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_some() + matches!( + classify_wire(wire), + WireKind::Block | WireKind::Circle | WireKind::Ellipse + ) } /// Single-pass classification and extraction of all viewport wire categories. @@ -233,10 +216,7 @@ pub fn partition_wires<'a>( instanced.push(wire); continue; } - // `wire_draw_depth` is a hash lookup and its result is used only by the - // analytical extractions below, which most wires never reach. Computing - // it first cost one lookup per resident wire on every patch — 236 956 - // of them for the ~56 000 that are circles or ellipses. + // Resolve draw depth only for wires eligible for analytical extraction. if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() && wire.pick_tris.is_empty() diff --git a/src/scene/selection.rs b/src/scene/selection.rs index de9248c31..d77e6d88a 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -360,21 +360,8 @@ impl Scene { self.selected.len() } - /// Sorted entity types in the current layout, cached until geometry or layout changes. - /// The entity type names present in the current layout, for the selection - /// filter menu. - /// - /// The status bar asks for this on **every** frame, and the cache used to - /// be keyed on `geometry_epoch` alone — so every drawn point invalidated it - /// and the walk below visited all 444 937 handles a model space owns. That - /// was ~100 ms per edit, the largest single cost of drawing a line, and - /// almost always to arrive at the same answer: adding a line to a drawing - /// that already has lines changes nothing here. - /// - /// An edit is now folded in from the delta journal instead. Additions only - /// ever grow the set, so they are cheap to apply; a removal can retire a - /// type and cannot be resolved from a handle that is already gone, so those - /// fall back to the full walk. + /// Entity type names in the current layout for the selection-filter menu. + /// Pure additions are folded into the cached set; other changes rebuild it. pub fn entity_type_names_in_layout(&self) -> std::sync::Arc> { use crate::entities::traits::entity_type_name; let block = self.current_layout_block_handle(); @@ -394,7 +381,10 @@ impl Scene { // Incremental: fold the changes since the cached epoch into the set. if let Some(since) = cached_epoch { if let Some(deltas) = self.replay_since(since) { - if !deltas.iter().any(|(_, kind)| *kind == ChangeKind::Removed) { + if deltas + .iter() + .all(|(_, kind)| *kind == ChangeKind::Added) + { let mut cache = self.layout_type_names_cache.borrow_mut(); if let Some((epoch, _, present, names)) = cache.as_mut() { let mut added = false; @@ -1013,10 +1003,6 @@ impl Scene { mod tests { use super::*; - /// The reason the cache carries a set as well as a list: drawing into a - /// drawing that already has that type must not rebuild either, because the - /// rebuild walks every entity the layout owns — 444 937 of them on the - /// reproducer, ~100 ms, on every single drawn point. #[test] fn adding_a_type_already_present_reuses_the_list() { use acadrust::entities::{Circle, EntityType, Line}; @@ -1053,6 +1039,24 @@ mod tests { ); } + #[test] + fn changing_an_entity_type_rebuilds_the_type_names() { + use acadrust::entities::{Circle, EntityType, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Line"]); + + let mut circle = Circle::new(); + circle.common.handle = handle; + assert!(scene.update_entity(EntityType::Circle(circle))); + assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Circle"]); + } + #[test] fn layout_type_cache_reuses_and_invalidates_on_edits_undo_and_layout() { use acadrust::entities::{Circle, EntityType, Line}; diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 026fa517b..331a536f5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -842,18 +842,8 @@ impl shader::Primitive for Primitive { gpus.extend(arena.wire_gpus()); } inner.gpu_wires = std::sync::Arc::new(gpus); - // A one-entity patch costs 73 ms in this block, and the - // three steps below are all O(resident set) rather than - // O(changes). Which of them dominates decides the fix. - // The partition walks the whole resident set, and on - // a patch it almost always produces the three uploads it - // produced last frame. Skip it when no changed entity - // feeds them and none did before. - // - // Safe because adding an entity does not disturb any - // other entity's draw depth: `inserted_draw_depth_label` - // allocates into the reserved label gap rather than - // renumbering, so instances already uploaded stay right. + // Retain analytical uploads when a patch changes neither + // their contributors nor their baked draw depths. let analytical_untouched = _patched && inner.partition_depth_generation == vp.draw_depth_generation && patch.is_some_and(|patch| { @@ -910,10 +900,7 @@ impl shader::Primitive for Primitive { .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); } if _perf { - // Counts only when a partition produced them. - // Printing zeros on a skip reads as "no circles" - // rather than "not recomputed", which is the - // opposite of what the line is reporting. + // Report counts only when partitioning ran. match &partitioned { Some(partitioned) => crate::perf_record!( "[perf] arena-post partition={part_ms:.1}ms \ From f16423ef8e08d4906a64cc4541164c81c931c09e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:35:51 +0300 Subject: [PATCH 34/40] Keep primitive properties editable after fillet --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- src/app/model_ops.rs | 5 +++++ src/scene/model/solid_history.rs | 19 +++++++++++++--- src/scene/modify.rs | 37 +++++++++++++++++++++++++++----- 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b85f7a77b..b552b07ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/ramox81/cadcodec.git?rev=af1f31287d4730f228cbb27d6884078295627ca7#af1f31287d4730f228cbb27d6884078295627ca7" +source = "git+https://github.com/ramox81/cadcodec.git?rev=5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc#5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536#f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" +source = "git+https://github.com/ramox81/cadkernel.git?rev=32c6bb1d3a09297e8482f572c1eb42f5d3a8c4a1#32c6bb1d3a09297e8482f572c1eb42f5d3a8c4a1" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 3fb177f53..623752926 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,10 +74,10 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "af1f31287d4730f228cbb27d6884078295627ca7" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" } [patch."https://github.com/HakanSeven12/cadkernel.git"] -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "f9cb9dbcbbc25e25a139accd4fbf1cf73ca1f536" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "32c6bb1d3a09297e8482f572c1eb42f5d3a8c4a1" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 16e87d6f1..96f61c596 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -652,6 +652,11 @@ impl super::OpenCADStudio { .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); return false; } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); let Some(EntityType::Solid3D(mut entity)) = self.tabs[i].scene.document.get_entity(handle).cloned() else { diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 0e86cd324..d2af62f09 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -198,7 +198,7 @@ pub fn has_specialized_primitive_properties( handle: acadrust::Handle, ) -> bool { matches!( - document.solid_history_operation(handle), + primitive_property_operation(document, handle).as_ref(), Some( SolidHistoryOperation::Box(_) | SolidHistoryOperation::Wedge(_) @@ -215,6 +215,19 @@ pub fn has_specialized_primitive_properties( ) } +/// Return the primitive that owns the public Geometry rows. Later edge +/// operations refine that primitive without replacing its editable type, +/// position, or dimensions. +pub fn primitive_property_operation( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Option { + document + .solid_history_operations(handle) + .and_then(|operations| operations.into_iter().next()) + .or_else(|| document.solid_history_operation(handle).cloned()) +} + /// Solid history results whose public Properties palette is fully described by /// the common entity rows plus [`primitive_properties`]. A generic B-rep has /// no stable primitive dimensions to expose, but it still uses the compact @@ -1439,10 +1452,10 @@ pub fn primitive_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> Vec { - let Some(operation) = document.solid_history_operation(handle) else { + let Some(operation) = primitive_property_operation(document, handle) else { return brep_properties(document, handle); }; - match operation { + match &operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 47dc462ca..43d016172 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -828,8 +828,13 @@ impl Scene { pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document - .solid_history_operation(handle) - .and_then(crate::scene::model::solid_history::reference_point); + .solid_history_operations(handle) + .and_then(|operations| { + operations + .iter() + .rev() + .find_map(crate::scene::model::solid_history::reference_point) + }); let Some(reference) = reference else { return; }; @@ -883,7 +888,24 @@ impl Scene { } (EntityType::Solid3D(_), _) => { let mut operations = self.document.solid_history_operations(handle)?; - *operations.last_mut()? = operation.clone(); + let replacement_id = operation.base().map(|base| { + if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + } + })?; + let target = operations.iter_mut().find(|candidate| { + candidate.base().is_some_and(|base| { + let node_id = if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + }; + node_id == replacement_id + }) + })?; + *target = operation.clone(); cadkernel::acis::rebuild_history(&operations).ok() } _ => None, @@ -938,7 +960,7 @@ impl Scene { self.record_solid_history_before(handle); if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -1199,7 +1221,12 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { + let Some(mut operation) = + crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) + else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property( From 058cd02042958a1894ddbd8a8ad5d97a156523dc Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 15:48:48 +0100 Subject: [PATCH 35/40] fix(pick): require all sub-geometry inside window selection for multi-wire and mesh entities --- src/scene/mod.rs | 164 +++++++++------- src/scene/pick/hit_test.rs | 374 ++++++++++++++++++++++++++++--------- 2 files changed, 387 insertions(+), 151 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 75bc719a5..f559ff4bc 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8342,24 +8342,33 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - if indices - .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_box_hit( - a, - b, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_box_hit( + a, + b, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() }) - { + }; + let mut sets = indices + .iter() + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; + if hit { out.push(handle); } } @@ -8510,23 +8519,32 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - if indices - .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_poly_hit( - poly, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_poly_hit( + poly, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() }) - { + }; + let mut sets = indices + .iter() + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; + if hit { out.push(handle); } } @@ -8768,23 +8786,32 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - let hit = indices + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_box_hit( + a, + b, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }; + let mut sets = indices .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_box_hit( - a, - b, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) - }); + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; if hit { out.push(handle); } @@ -8819,22 +8846,31 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - let hit = indices + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_poly_hit( + poly, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }; + let mut sets = indices .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_poly_hit( - poly, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) - }); + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; if hit { out.push(handle); } diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 209a88ba1..f9520c760 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -1615,10 +1615,10 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( ); } - // Q: lazy projection — accumulate screen points without allocating per-wire Vec. - wires - .iter() - .filter_map(|wire| { + if crossing { + let mut out = Vec::new(); + let mut seen = HashSet::default(); + for wire in wires.iter() { // Fallback: when wire has no line geometry (e.g. greek text emits // only fill_tris) treat the AABB rectangle as the hit-test shape // so low-LOD text stays selectable. See #19. @@ -1639,7 +1639,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( ]; &aabb_pts } else { - return None; + continue; }; // Low residual parallel to `pts` (empty for the AABB fallback, @@ -1650,6 +1650,85 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( &[] }; let mut hit = false; + let mut prev: Option = None; + + for (i, &[px, py, pz]) in pts.iter().enumerate() { + if px.is_nan() { + prev = None; + continue; + } + let world = if !wire.points.is_empty() { + wire_point_world(wire, i, view_rot, eye) + } else { + wp64([px, py, pz], low, i) + }; + let sp = world_to_screen(world, view_rot, eye, bounds); + if inside(sp) { + hit = true; + } + if let Some(p0) = prev { + if !hit { + hit = segments_intersect(p0, sp, box_tl, box_tr) + || segments_intersect(p0, sp, box_tr, box_br) + || segments_intersect(p0, sp, box_br, box_bl) + || segments_intersect(p0, sp, box_bl, box_tl); + } + } + prev = Some(sp); + } + + let mut glyph_crosses = false; + for start in (0..wire.text_verts.len()).step_by(6) { + let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { + continue; + }; + if [0usize, 3].into_iter().any(|offset| { + triangle_crosses_box( + [screen[offset], screen[offset + 1], screen[offset + 2]], + box_corners, + ) + }) { + glyph_crosses = true; + break; + } + } + + if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + out + } else { + let mut qualified = Vec::new(); + let mut disqualified = HashSet::default(); + let mut seen = HashSet::default(); + + for wire in wires.iter() { + let aabb_pts: Vec<[f32; 3]>; + let empty_pts: [[f32; 3]; 0] = []; + let pts: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points + } else if !wire.text_verts.is_empty() { + &empty_pts + } else if wire.aabb != WireModel::UNBOUNDED_AABB { + let [ax, ay, bx, by] = wire.aabb; + aabb_pts = vec![ + [ax, ay, 0.0], + [bx, ay, 0.0], + [bx, by, 0.0], + [ax, by, 0.0], + [ax, ay, 0.0], + ]; + &aabb_pts + } else { + continue; + }; + + let low: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points_low + } else { + &[] + }; let mut all_inside = true; let mut prev: Option = None; @@ -1664,61 +1743,42 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( wp64([px, py, pz], low, i) }; let sp = world_to_screen(world, view_rot, eye, bounds); - if crossing { - if inside(sp) { - hit = true; - } - if let Some(p0) = prev { - if !hit { - hit = segments_intersect(p0, sp, box_tl, box_tr) - || segments_intersect(p0, sp, box_tr, box_br) - || segments_intersect(p0, sp, box_br, box_bl) - || segments_intersect(p0, sp, box_bl, box_tl); - } - } - } else { - if !inside(sp) { - all_inside = false; - } + if !inside(sp) { + all_inside = false; } prev = Some(sp); } let glyphs_present = !wire.text_verts.is_empty(); - let mut glyph_crosses = false; let mut glyphs_inside = true; for start in (0..wire.text_verts.len()).step_by(6) { let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { continue; }; - if crossing { - if [0usize, 3].into_iter().any(|offset| { - triangle_crosses_box( - [screen[offset], screen[offset + 1], screen[offset + 2]], - box_corners, - ) - }) { - glyph_crosses = true; - break; - } - } else if !screen.iter().copied().all(inside) { + if !screen.iter().copied().all(inside) { glyphs_inside = false; break; } } - let result = if crossing { - hit || glyph_crosses - } else { - all_inside && glyphs_inside && (prev.is_some() || glyphs_present) - }; - if result { - Some(wire.name.as_str()) - } else { - None + let has_geom = prev.is_some() || glyphs_present; + if !has_geom { + continue; } - }) - .collect() + + let name = wire.name.as_str(); + if all_inside && glyphs_inside { + if seen.insert(name) { + qualified.push(name); + } + } else { + disqualified.insert(name); + } + } + + qualified.retain(|name| !disqualified.contains(name)); + qualified + } } // ── Polygon / lasso selection ───────────────────────────────────────────── @@ -1743,10 +1803,10 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( return indexed_polygon_crossing_hits(wires, poly, view_rot, eye, bounds); } - // Q: lazy projection — no Vec allocation per wire. - wires - .iter() - .filter_map(|wire| { + if crossing { + let mut out = Vec::new(); + let mut seen = HashSet::default(); + for wire in wires.iter() { // Same AABB fallback as `box_hit`: when a wire has no line // geometry (e.g. greek-LOD text emits only fill_tris) treat the // AABB rectangle as the hit-test shape so low-LOD text stays @@ -1768,7 +1828,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( ]; &aabb_pts } else { - return None; + continue; }; let low: &[[f32; 3]] = if !wire.points.is_empty() { @@ -1777,6 +1837,88 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( &[] }; let mut hit = false; + let mut prev: Option = None; + + for (i, &[px, py, pz]) in pts.iter().enumerate() { + if px.is_nan() { + prev = None; + continue; + } + let world = if !wire.points.is_empty() { + wire_point_world(wire, i, view_rot, eye) + } else { + wp64([px, py, pz], low, i) + }; + let sp = world_to_screen(world, view_rot, eye, bounds); + if sp.x < 0.0 || sp.x > bounds.width || sp.y < 0.0 || sp.y > bounds.height { + prev = None; + continue; + } + if point_in_polygon(sp, poly) { + hit = true; + } + if !hit { + if let Some(p0) = prev { + if segment_crosses_polygon(p0, sp, poly) { + hit = true; + } + } + } + prev = Some(sp); + } + + let mut glyph_crosses = false; + for start in (0..wire.text_verts.len()).step_by(6) { + let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { + continue; + }; + if [0usize, 3].into_iter().any(|offset| { + triangle_crosses_polygon( + [screen[offset], screen[offset + 1], screen[offset + 2]], + poly, + ) + }) { + glyph_crosses = true; + break; + } + } + + if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + out + } else { + let mut qualified = Vec::new(); + let mut disqualified = HashSet::default(); + let mut seen = HashSet::default(); + + for wire in wires.iter() { + let aabb_pts: Vec<[f32; 3]>; + let empty_pts: [[f32; 3]; 0] = []; + let pts: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points + } else if !wire.text_verts.is_empty() { + &empty_pts + } else if wire.aabb != WireModel::UNBOUNDED_AABB { + let [ax, ay, bx, by] = wire.aabb; + aabb_pts = vec![ + [ax, ay, 0.0], + [bx, ay, 0.0], + [bx, by, 0.0], + [ax, by, 0.0], + [ax, ay, 0.0], + ]; + &aabb_pts + } else { + continue; + }; + + let low: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points_low + } else { + &[] + }; let mut all_inside = true; let mut prev: Option = None; @@ -1798,43 +1940,19 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( prev = None; continue; } - if crossing { - if point_in_polygon(sp, poly) { - hit = true; - } - if !hit { - if let Some(p0) = prev { - if segment_crosses_polygon(p0, sp, poly) { - hit = true; - } - } - } - } else { - if !point_in_polygon(sp, poly) { - all_inside = false; - } + if !point_in_polygon(sp, poly) { + all_inside = false; } prev = Some(sp); } let glyphs_present = !wire.text_verts.is_empty(); - let mut glyph_crosses = false; let mut glyphs_inside = true; for start in (0..wire.text_verts.len()).step_by(6) { let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { continue; }; - if crossing { - if [0usize, 3].into_iter().any(|offset| { - triangle_crosses_polygon( - [screen[offset], screen[offset + 1], screen[offset + 2]], - poly, - ) - }) { - glyph_crosses = true; - break; - } - } else if !screen.iter().copied().all(|point| { + if !screen.iter().copied().all(|point| { point.x >= 0.0 && point.x <= bounds.width && point.y >= 0.0 @@ -1846,18 +1964,24 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( } } - let result = if crossing { - hit || glyph_crosses - } else { - all_inside && glyphs_inside && (prev.is_some() || glyphs_present) - }; - if result { - Some(wire.name.as_str()) - } else { - None + let has_geom = prev.is_some() || glyphs_present; + if !has_geom { + continue; } - }) - .collect() + + let name = wire.name.as_str(); + if all_inside && glyphs_inside { + if seen.insert(name) { + qualified.push(name); + } + } else { + disqualified.insert(name); + } + } + + qualified.retain(|name| !disqualified.contains(name)); + qualified + } } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -2511,4 +2635,80 @@ mod aabb_reject_tests { vec!["479"], ); } + + #[test] + fn window_selection_multi_wire_entity_behavior() { + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; + // In identity ortho view: + // world (0.0, 0.0) -> screen (100.0, 100.0) + // world (0.1, 0.1) -> screen (110.0, 90.0) + // world (0.8, 0.8) -> screen (180.0, 20.0) + let wire_arc = wire( + "4F", + vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]], + [0.0, 0.0, 0.1, 0.1], + ); + let wire_lines = wire( + "4F", + vec![[0.1, 0.1, 0.0], [0.8, 0.8, 0.0]], + [0.1, 0.1, 0.8, 0.8], + ); + let wires = [wire_arc, wire_lines]; + + // Box enclosing ONLY wire_arc (screen 95..115, 85..105): + let box_a = Point::new(95.0, 85.0); + let box_b = Point::new(115.0, 105.0); + let poly_small = [ + Point::new(95.0, 85.0), + Point::new(115.0, 85.0), + Point::new(115.0, 105.0), + Point::new(95.0, 105.0), + ]; + + // Crossing mode selects "4F" because wire_arc is inside: + assert_eq!( + box_hit(box_a, box_b, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + assert_eq!( + poly_hit(&poly_small, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + + // Window mode MUST NOT select "4F" because wire_lines has a vertex at (0.8, 0.8) -> (180.0, 20.0) outside: + assert_eq!( + box_hit(box_a, box_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + Vec::<&str>::new() + ); + assert_eq!( + poly_hit(&poly_small, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + Vec::<&str>::new() + ); + + // Big box enclosing BOTH wire_arc and wire_lines (screen 50..190, 10..110): + let big_a = Point::new(50.0, 10.0); + let big_b = Point::new(190.0, 110.0); + let poly_big = [ + Point::new(50.0, 10.0), + Point::new(190.0, 10.0), + Point::new(190.0, 110.0), + Point::new(50.0, 110.0), + ]; + + // Window mode now selects "4F" once (deduplicated): + assert_eq!( + box_hit(big_a, big_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + assert_eq!( + poly_hit(&poly_big, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + } } + From e6e60c136a37e328c373e76e9e5b122c4e79f2d0 Mon Sep 17 00:00:00 2001 From: Hydro Engineer <45497435+mf4633@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:56:19 -0400 Subject: [PATCH 36/40] registry: note Storm Sewer free/Pro split --- plugins/registry.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/registry.json b/plugins/registry.json index 724602992..22fe274f9 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -7,7 +7,7 @@ { "repo": "mf4633/opencad-storm-sewer-plugin", "name": "Storm Sewer", - "description": "Gravity storm-drain network design and analysis (Rational + Manning + HGL, LandXML import)." + "description": "Gravity storm-drain network design and analysis (Rational + Manning + HGL, LandXML import). Free to draw and analyze; Pro ($29/yr) adds HTML reports, pipe sizing, multi-RP." }, { "repo": "mf4633/opencad-hydrocomplete-plugin", From 4f22d998e7a187b5d621375ce1c2e14a38a53c72 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:08:52 +0300 Subject: [PATCH 37/40] Complete independent CHAMFEREDGE workflow --- Cargo.lock | 4 +- Cargo.toml | 5 +- src/app/command_driver.rs | 22 ++ src/app/commands/dim.rs | 91 ++++- src/app/model_ops.rs | 171 ++++++++- src/app/update/dynamic.rs | 8 + src/app/update/viewport.rs | 8 + src/command.rs | 8 + src/modules/model/chamferedge_cmd.rs | 536 +++++++++++++++++++++++++++ src/modules/model/mod.rs | 3 +- src/scene/entity.rs | 4 +- src/scene/model/solid_history.rs | 138 ++++++- src/scene/modify.rs | 126 ++++++- 13 files changed, 1098 insertions(+), 26 deletions(-) create mode 100644 src/modules/model/chamferedge_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 326bb7474..7226c8c8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b56571a190e7a17c8f12d36390d2938b0fb72f7#5b56571a190e7a17c8f12d36390d2938b0fb72f7" +source = "git+https://github.com/ramox81/cadcodec.git?rev=5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc#5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192" +source = "git+https://github.com/ramox81/cadkernel.git?rev=daca8b7392cedd036b85a80499643a6690833011#daca8b7392cedd036b85a80499643a6690833011" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 21a3ba4a8..2769b0a14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,10 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7" } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "5c0c01dcad4543dde2ee8bcd0e08131dfafbf2dc" } + +[patch."https://github.com/HakanSeven12/cadkernel.git"] +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "daca8b7392cedd036b85a80499643a6690833011" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b74e15fd..a0a8bf5af 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -733,6 +733,7 @@ impl OpenCADStudio { &result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..) + | CmdResult::SolidChamferEdges { .. } | CmdResult::SolidSubtract { .. } ); let task = self.apply_cmd_result_inner(result); @@ -4115,6 +4116,27 @@ impl OpenCADStudio { return task; } + CmdResult::SolidChamferEdges { + handle, + edges, + base_face, + distance1, + distance2, + } => { + let task = self.solid_chamfer_edges( + handle, + &edges, + base_face, + distance1, + distance2, + ); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::SolidSubtract { bases, cutters, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index b5c2a0eaf..4d86a78d6 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -17,6 +17,49 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { } } +fn selected_chamfer_body(app: &OpenCADStudio, tab: usize) -> Option { + let scene = &app.tabs.get(tab)?.scene; + let selected = scene.selected_handles_in_order(); + let [handle] = selected.as_slice() else { + return None; + }; + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_)) + ) + .then_some(*handle) +} + +fn chamfer_edge_sources( + app: &mut OpenCADStudio, + tab: usize, +) -> Vec<(acadrust::Handle, cadkernel::brep::Body)> { + let handles = app.tabs[tab] + .scene + .document + .entities() + .filter_map(|entity| { + matches!( + entity, + acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_) + ) + .then_some(entity.common().handle) + }) + .collect::>(); + app.tabs[tab].scene.restore_solid_models(&handles); + handles + .into_iter() + .filter_map(|handle| { + app.tabs[tab] + .scene + .solid_models + .get(&handle) + .cloned() + .map(|body| (handle, body)) + }) + .collect() +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -1107,16 +1150,52 @@ impl OpenCADStudio { } } - "SOLIDCHAMFER" => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + "CHAMFEREDGE" | "SOLIDCHAMFER" => { + use crate::modules::model::chamferedge_cmd::ChamferEdgeCommand; + let target = selected_chamfer_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); + let bodies = chamfer_edge_sources(self, i); + let command = ChamferEdgeCommand::new( + target, + bodies, + crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } - "CHAMFER" if selected_solid(self, i).is_some() => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + "CHAMFER" if selected_chamfer_body(self, i).is_some() => { + use crate::modules::model::chamferedge_cmd::ChamferEdgeCommand; + let target = selected_chamfer_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); + let bodies = chamfer_edge_sources(self, i); + let command = ChamferEdgeCommand::new( + target, + bodies, + crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a262c0ee3..478594762 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -7,7 +7,7 @@ use acadrust::{ objects::SolidHistoryOperation, EntityType, Handle, }; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use iced::Task; use std::collections::HashMap; @@ -244,6 +244,29 @@ fn entity_with_boolean_body(mut source: EntityType, body: &Body) -> Option Option { + let document = crate::scene::convert::acis_export::solid_to_sat(body)?; + let wires = solid_model::edge_wires(body); + match &mut source { + EntityType::Solid3D(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + if entity.kind != SurfaceKind::Plane { + entity.kind = SurfaceKind::Generic; + entity.surface_data = SurfaceData::Generic; + } + entity.acis_data = AcisData::from_sat(&document.to_sat_string()); + } + _ => return None, + } + Some(source) +} + enum IntersectBodyOutcome { Area(Body), Touching, @@ -646,6 +669,152 @@ impl super::OpenCADStudio { Task::none() } + fn replace_edge_body_with_chamfer( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + base_face: FaceKey, + distance1: f64, + distance2: f64, + ) -> bool { + let i = self.active_tab; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref()); + return false; + }; + let ordered_edges = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered_edges + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); + return false; + }; + let Some(history_face) = source + .face_keys() + .position(|candidate| candidate == base_face) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + else { + self.command_line.push_error( + crate::t!("The selected base face no longer belongs to the body.").as_ref(), + ); + return false; + }; + + self.push_undo_snapshot(i, "CHAMFEREDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) + { + self.command_line + .push_error(crate::t!("The edge body history could not be created.").as_ref()); + return false; + } + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::chamfer_op(history_edges, history_face, distance1, distance2), + ) { + self.command_line + .push_error(crate::t!("The chamfer history could not be recorded.").as_ref()); + return false; + } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return false; + }; + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); + return false; + } + self.tabs[i] + .scene + .register_prepared_solid_model(handle, result, display); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn solid_chamfer_edges( + &mut self, + handle: Handle, + edges: &[EdgeKey], + base_face: FaceKey, + distance1: f64, + distance2: f64, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(EntityType::Solid3D(_) | EntityType::Surface(_)) + ) { + self.command_line + .push_error(crate::t!("Select a 3D solid or surface edge.").as_ref()); + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The edge body geometry could not be restored.").as_ref()); + return Task::none(); + }; + let result = match cadkernel::brep::chamfer_edges( + &body, + edges, + base_face, + distance1, + distance2, + ) { + Ok(result) => result, + Err(error) => { + self.command_line + .push_error(&format!("CHAMFEREDGE: {error}")); + return Task::none(); + } + }; + if self.replace_edge_body_with_chamfer( + handle, + &body, + result, + edges, + base_face, + distance1, + distance2, + ) { + self.tabs[i].scene.document.header.chamfer_distance_a = distance1; + self.tabs[i].scene.document.header.chamfer_distance_b = distance2; + self.command_line + .push_output(crate::t!("CHAMFEREDGE: solid updated.").as_ref()); + } + Task::none() + } + /// Intersect compatible selected solids, Regions, and coplanar Surfaces. pub(super) fn solid_intersect(&mut self) -> Task { let i = self.active_tab; diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index feaf6b016..1e1888055 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -740,6 +740,14 @@ impl OpenCADStudio { .as_mut() .map(|c| c.on_preview_wires(cur)) .unwrap_or_default(); + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c39916fe4..12c339a01 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2468,6 +2468,14 @@ impl OpenCADStudio { previews.push(guide); } } + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } else { self.tabs[i].snap_result = None; diff --git a/src/command.rs b/src/command.rs index 25ccfb027..d3cc7f4d7 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1554,6 +1554,14 @@ pub enum CmdResult { value: f64, fillet: bool, }, + /// Chamfer one or more resolved B-rep edges on a common base face. + SolidChamferEdges { + handle: Handle, + edges: Vec, + base_face: cadkernel::brep::FaceKey, + distance1: f64, + distance2: f64, + }, SolidSubtract { bases: Vec, cutters: Vec, diff --git a/src/modules/model/chamferedge_cmd.rs b/src/modules/model/chamferedge_cmd.rs new file mode 100644 index 000000000..d781fa331 --- /dev/null +++ b/src/modules/model/chamferedge_cmd.rs @@ -0,0 +1,536 @@ +use acadrust::Handle; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, +}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ChamferStep { + Selecting, + PickingLoop, + LoopConfirm, + PreviewConfirm, + Distance1, + Distance2, + Expression, +} + +pub struct ChamferEdgeCommand { + target: Option, + bodies: Vec<(Handle, Body)>, + handle: Option, + selected_edges: Vec, + base_face: Option, + selection_batches: Vec<(Vec, DVec3)>, + step: ChamferStep, + value_return: ChamferStep, + expression_return: ChamferStep, + loop_candidates: Vec<(FaceKey, Vec)>, + loop_index: usize, + last_pick: Option, + distance1: f64, + distance2: f64, + preview_color: [f32; 4], + preview_wires: Vec, + preview_hidden: Vec, +} + +impl ChamferEdgeCommand { + pub fn new( + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + distances: (f64, f64), + ) -> Self { + Self { + target, + bodies, + handle: None, + selected_edges: Vec::new(), + base_face: None, + selection_batches: Vec::new(), + step: ChamferStep::Selecting, + value_return: ChamferStep::Selecting, + expression_return: ChamferStep::Distance1, + loop_candidates: Vec::new(), + loop_index: 0, + last_pick: None, + distance1: positive_default(distances.0), + distance2: positive_default(distances.1), + preview_color, + preview_wires: Vec::new(), + preview_hidden: Vec::new(), + } + } + + pub fn distances(&self) -> (f64, f64) { + (self.distance1, self.distance2) + } + + fn body(&self, handle: Handle) -> Option<&Body> { + self.bodies + .iter() + .find_map(|(candidate, body)| (*candidate == handle).then_some(body)) + } + + fn active_body(&self) -> Option<(Handle, &Body)> { + let handle = self.handle?; + self.body(handle).map(|body| (handle, body)) + } + + fn pick_allowed(&self, handle: Handle) -> bool { + !handle.is_null() + && self.target.is_none_or(|target| target == handle) + && self.handle.is_none_or(|selected| selected == handle) + && self.body(handle).is_some() + } + + fn add_batch(&mut self, edges: Vec, anchor: DVec3) -> bool { + let batch = edges + .into_iter() + .filter(|edge| !self.selected_edges.contains(edge)) + .collect::>(); + if batch.is_empty() { + return false; + } + self.selected_edges.extend(batch.iter().copied()); + self.selection_batches.push((batch, anchor)); + true + } + + fn current_loop(&self) -> Option<&(FaceKey, Vec)> { + self.loop_candidates.get(self.loop_index) + } + + fn preview_edges(&self) -> Vec { + let mut edges = self.selected_edges.clone(); + if self.step == ChamferStep::LoopConfirm { + if let Some((_, loop_edges)) = self.current_loop() { + for edge in loop_edges { + if !edges.contains(edge) { + edges.push(*edge); + } + } + } + } + edges + } + + fn preview_for_values( + &self, + distance1: f64, + distance2: f64, + ) -> Option<(Handle, Vec)> { + let edges = self.preview_edges(); + if edges.is_empty() { + return None; + } + let (handle, body) = self.active_body()?; + let base_face = self.base_face.or_else(|| self.current_loop().map(|item| item.0))?; + let result = + cadkernel::brep::chamfer_edges(body, &edges, base_face, distance1, distance2).ok()?; + let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + for wire in &mut wires { + wire.color = self.preview_color; + wire.name = format!("{}-CHAMFEREDGE-PREVIEW", handle.value()); + } + (!wires.is_empty()).then_some((handle, wires)) + } + + fn rebuild_preview(&mut self) { + self.preview_wires.clear(); + self.preview_hidden.clear(); + if let Some((handle, wires)) = self.preview_for_values(self.distance1, self.distance2) { + self.preview_wires = wires; + self.preview_hidden.push(handle); + } + } + + fn begin_distances(&mut self, return_to: ChamferStep) -> CmdResult { + self.value_return = return_to; + self.step = ChamferStep::Distance1; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_distance(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + match self.step { + ChamferStep::Distance1 => { + self.distance1 = value; + self.step = ChamferStep::Distance2; + } + ChamferStep::Distance2 => { + self.distance2 = value; + self.step = self.value_return; + } + _ => return None, + } + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn accept_loop(&mut self) -> CmdResult { + if let (Some((face, edges)), Some(anchor)) = (self.current_loop().cloned(), self.last_pick) { + if self.base_face.is_none() { + self.base_face = Some(face); + } + self.add_batch(edges, anchor); + } + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = ChamferStep::Selecting; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn finish(&self) -> CmdResult { + let (Some(handle), Some(base_face)) = (self.handle, self.base_face) else { + return CmdResult::Cancel; + }; + if self.selected_edges.is_empty() { + return CmdResult::Cancel; + } + CmdResult::SolidChamferEdges { + handle, + edges: self.selected_edges.clone(), + base_face, + distance1: self.distance1, + distance2: self.distance2, + } + } +} + +impl CadCommand for ChamferEdgeCommand { + fn name(&self) -> &'static str { + "CHAMFEREDGE" + } + + fn prompt(&self) -> String { + match self.step { + ChamferStep::Selecting => crate::t!("Select an edge or [Loop/Distance]:").into_owned(), + ChamferStep::PickingLoop => { + crate::t!("Select edge of loop or [Edge/Distance]:").into_owned() + } + ChamferStep::LoopConfirm => { + crate::t!("Enter an option [Accept/Next] :").into_owned() + } + ChamferStep::PreviewConfirm => { + crate::t!("Press Enter to accept the chamfer or [Distance]:").into_owned() + } + ChamferStep::Distance1 => { + crate::tf!("Specify Distance1 or [Expression] <{:.4}>:", self.distance1) + .into_owned() + } + ChamferStep::Distance2 => { + crate::tf!("Specify Distance2 or [Expression] <{:.4}>:", self.distance2) + .into_owned() + } + ChamferStep::Expression => crate::t!("Enter expression:").into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + ChamferStep::Selecting => vec![ + CmdOption::new("Loop", "L"), + CmdOption::new("Distance", "D"), + ], + ChamferStep::PickingLoop => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Distance", "D"), + ], + ChamferStep::LoopConfirm => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + ChamferStep::PreviewConfirm => vec![CmdOption::new("Distance", "D")], + ChamferStep::Distance1 | ChamferStep::Distance2 => { + vec![CmdOption::new("Expression", "E")] + } + ChamferStep::Expression => Vec::new(), + } + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, ChamferStep::Selecting | ChamferStep::PickingLoop) + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if !self.pick_allowed(handle) { + return CmdResult::NeedPoint; + } + let Some(seed) = self + .body(handle) + .and_then(|body| crate::scene::model::solid_model::nearest_edge(body, point.to_array())) + else { + return CmdResult::NeedPoint; + }; + self.handle = Some(handle); + self.last_pick = Some(point); + + if self.step == ChamferStep::PickingLoop { + let mut candidates = edge_loops(self.body(handle).expect("pick body exists"), seed); + if let Some(base_face) = self.base_face { + candidates.retain(|(face, _)| *face == base_face); + } + self.loop_candidates = candidates; + self.loop_index = 0; + self.step = if self.loop_candidates.is_empty() { + ChamferStep::Selecting + } else { + ChamferStep::LoopConfirm + }; + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + + if self.base_face.is_none() { + self.base_face = self + .body(handle) + .and_then(|body| nearest_edge_face(body, seed, point.to_array())); + } + let Some(base_face) = self.base_face else { + return CmdResult::NeedPoint; + }; + if !edge_belongs_to_face(self.body(handle).expect("pick body exists"), seed, base_face) { + return CmdResult::NeedPoint; + } + self.add_batch(vec![seed], point); + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + if !matches!(self.step, ChamferStep::Distance1 | ChamferStep::Distance2) { + return CmdResult::NeedPoint; + } + let Some(anchor) = self.last_pick else { + return CmdResult::NeedPoint; + }; + self.accept_distance(point.distance(anchor)) + .unwrap_or(CmdResult::NeedPoint) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + match self.step { + ChamferStep::Selecting => match keyword.as_str() { + "L" | "LOOP" => { + self.step = ChamferStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(ChamferStep::Selecting)), + _ => None, + }, + ChamferStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.step = ChamferStep::Selecting; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(ChamferStep::PickingLoop)), + _ => None, + }, + ChamferStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ChamferStep::PreviewConfirm => match keyword.as_str() { + "D" | "DISTANCE" => Some(self.begin_distances(ChamferStep::PreviewConfirm)), + _ => None, + }, + ChamferStep::Distance1 | ChamferStep::Distance2 => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.expression_return = self.step; + self.step = ChamferStep::Expression; + Some(CmdResult::NeedPoint) + } else { + self.accept_distance(keyword.parse::().ok()?) + } + } + ChamferStep::Expression => { + let value = crate::app::expr_eval::eval_number(text)?; + self.step = self.expression_return; + self.accept_distance(value) + } + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + ChamferStep::Selecting if !self.selected_edges.is_empty() => { + self.step = ChamferStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + ChamferStep::LoopConfirm => self.accept_loop(), + ChamferStep::PreviewConfirm => self.finish(), + ChamferStep::Distance1 => self + .accept_distance(self.distance1) + .unwrap_or(CmdResult::NeedPoint), + ChamferStep::Distance2 => self + .accept_distance(self.distance2) + .unwrap_or(CmdResult::NeedPoint), + ChamferStep::Expression => CmdResult::NeedPoint, + _ => CmdResult::Cancel, + } + } + + fn on_undo_step(&mut self) -> Option { + if self.step == ChamferStep::LoopConfirm { + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = ChamferStep::Selecting; + self.rebuild_preview(); + return Some(CmdResult::NeedPoint); + } + let (batch, _) = self.selection_batches.pop()?; + self.selected_edges.retain(|edge| !batch.contains(edge)); + if self.selected_edges.is_empty() { + self.handle = None; + self.base_face = None; + } + self.last_pick = self.selection_batches.last().map(|(_, anchor)| *anchor); + self.step = ChamferStep::Selecting; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn wants_text_input(&self) -> bool { + matches!( + self.step, + ChamferStep::Distance1 | ChamferStep::Distance2 | ChamferStep::Expression + ) + } + + fn dyn_commit_as_text(&self) -> bool { + matches!(self.step, ChamferStep::Distance1 | ChamferStep::Distance2) + } + + fn dyn_spec(&self) -> Option { + let pick = self.last_pick?; + matches!(self.step, ChamferStep::Distance1 | ChamferStep::Distance2).then(|| DynSpec { + anchor: DynAnchor::Point(pick), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + matches!(self.step, ChamferStep::Distance1 | ChamferStep::Distance2) + .then(|| self.last_pick.map(|pick| cursor.distance(pick))) + .flatten() + } + + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + if matches!(self.step, ChamferStep::Distance1 | ChamferStep::Distance2) { + if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) { + if value > 0.0 && value.is_finite() { + let values = if self.step == ChamferStep::Distance2 { + (self.distance1, value) + } else { + (value, self.distance2) + }; + if let Some((handle, wires)) = self.preview_for_values(values.0, values.1) { + self.preview_wires = wires; + self.preview_hidden = vec![handle]; + } else { + self.preview_wires.clear(); + self.preview_hidden.clear(); + } + } + } + } + self.preview_wires.clone() + } + + fn preview_hidden_handles(&self) -> &[Handle] { + &self.preview_hidden + } +} + +fn edge_loops(body: &Body, seed: EdgeKey) -> Vec<(FaceKey, Vec)> { + let Some(edge) = body.edges.get(seed) else { + return Vec::new(); + }; + let mut candidates = Vec::new(); + for coedge_key in &edge.coedges { + let Some(coedge) = body.coedges.get(*coedge_key) else { + continue; + }; + let Some(edge_loop) = body.loops.get(coedge.owner) else { + continue; + }; + let edges = edge_loop + .coedges + .iter() + .filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge)) + .collect::>(); + let candidate = (edge_loop.owner, edges); + if !candidate.1.is_empty() && !candidates.contains(&candidate) { + candidates.push(candidate); + } + } + candidates +} + +fn edge_belongs_to_face(body: &Body, edge: EdgeKey, face: FaceKey) -> bool { + body.edges.get(edge).is_some_and(|edge| { + edge.coedges.iter().any(|coedge| { + body.coedges + .get(*coedge) + .and_then(|coedge| body.loops.get(coedge.owner)) + .is_some_and(|edge_loop| edge_loop.owner == face) + }) + }) +} + +fn nearest_edge_face(body: &Body, edge: EdgeKey, point: [f64; 3]) -> Option { + let edge = body.edges.get(edge)?; + edge.coedges + .iter() + .filter_map(|coedge| { + let edge_loop = body.loops.get(body.coedges.get(*coedge)?.owner)?; + let face = body.faces.get(edge_loop.owner)?; + let surface = body.surfaces.get(face.surface)?; + let distance = surface.distance_to(point).abs(); + distance.is_finite().then_some((edge_loop.owner, distance)) + }) + .min_by(|first, second| first.1.total_cmp(&second.1)) + .map(|(face, _)| face) +} + +fn positive_default(value: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else { + 1.0 + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["CHAMFEREDGE"] +}); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..89c3d7e82 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -1,6 +1,7 @@ // Solid creation and kernel modelling tools. pub mod boolean_cmd; +pub mod chamferedge_cmd; pub mod cylinder_cmd; pub mod edge_cmd; pub mod polysolid_cmd; @@ -89,7 +90,7 @@ impl CadModule for ModelModule { title: "Edges", tools: vec![ RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), - RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("CHAMFEREDGE", "Chamfer", CHAMFER_ICON)), ], }, ] diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a1dbbf3af..ca4609047 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -596,8 +596,8 @@ impl Scene { .filter_map(|&handle| { let from_history = self .document - .solid_history_operation(handle) - .and_then(|operation| cadkernel::acis::rebuild_body(operation).ok()); + .solid_history_operations(handle) + .and_then(|operations| cadkernel::acis::rebuild_history(&operations).ok()); let body = from_history.or_else(|| match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index c52d28732..920c8171c 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,12 +1,13 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ - DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, + DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryChamfer, + SolidHistoryCone, SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, + SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; use acadrust::EntityType; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, Surface as KernelSurface}; use crate::command::EntityTransform; use crate::entities::traits::EntityTypeOps; @@ -32,6 +33,8 @@ pub const GRIP_SWEEP_PATH_FIRST: usize = 30_000; pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000; pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000; pub const GRIP_REVOLVE_AXIS: usize = 10_013; +pub const GRIP_CHAMFER_DISTANCE1: usize = 10_015; +pub const GRIP_CHAMFER_DISTANCE2: usize = 10_016; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -197,7 +200,7 @@ pub fn has_specialized_primitive_properties( handle: acadrust::Handle, ) -> bool { matches!( - document.solid_history_operation(handle), + primitive_property_operation(document, handle).as_ref(), Some( SolidHistoryOperation::Box(_) | SolidHistoryOperation::Wedge(_) @@ -214,6 +217,18 @@ pub fn has_specialized_primitive_properties( ) } +/// Returns the primitive that owns the public Geometry rows. Later edge +/// operations refine it without replacing its editable type and dimensions. +pub fn primitive_property_operation( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Option { + document + .solid_history_operations(handle) + .and_then(|operations| operations.into_iter().next()) + .or_else(|| document.solid_history_operation(handle).cloned()) +} + /// Solid history results whose public Properties palette is fully described by /// the common entity rows plus [`primitive_properties`]. A generic B-rep has /// no stable primitive dimensions to expose, but it still uses the compact @@ -1438,10 +1453,10 @@ pub fn primitive_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> Vec { - let Some(operation) = document.solid_history_operation(handle) else { + let Some(operation) = primitive_property_operation(document, handle) else { return brep_properties(document, handle); }; - match operation { + match &operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") } @@ -3086,6 +3101,96 @@ fn grip( } } +pub fn chamfer_distance_grips( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryChamfer, +) -> Vec { + let Some(operations) = document.solid_history_operations(handle) else { + return Vec::new(); + }; + let Some(chamfer_index) = operations + .iter() + .rposition(|operation| matches!(operation, SolidHistoryOperation::Chamfer(_))) + else { + return Vec::new(); + }; + let Ok(source) = cadkernel::acis::rebuild_history(&operations[..chamfer_index]) else { + return Vec::new(); + }; + let edges = source.edge_keys().collect::>(); + let faces = source.face_keys().collect::>(); + let Some(edge) = value + .edges + .first() + .and_then(|ordinal| usize::try_from(*ordinal).ok()) + .and_then(|ordinal| edges.get(ordinal).copied()) + .and_then(|edge| source.edges.get(edge)) + else { + return Vec::new(); + }; + let Some(base_face) = usize::try_from(value.base_face) + .ok() + .and_then(|ordinal| faces.get(ordinal).copied()) + else { + return Vec::new(); + }; + let adjacent = edge + .coedges + .iter() + .filter_map(|coedge| source.coedges.get(*coedge)) + .filter_map(|coedge| source.loops.get(coedge.owner)) + .map(|edge_loop| edge_loop.owner) + .collect::>(); + let Some(other_face) = adjacent.iter().copied().find(|face| *face != base_face) else { + return Vec::new(); + }; + let outward = |face_key| { + let face = source.faces.get(face_key)?; + let KernelSurface::Plane(plane) = source.surfaces.get(face.surface)? else { + return None; + }; + let normal = glam::DVec3::from_array(plane.normal()?).try_normalize()?; + Some(if face.forward { normal } else { -normal }) + }; + let Some(base_normal) = outward(base_face) else { + return Vec::new(); + }; + let Some(other_normal) = outward(other_face) else { + return Vec::new(); + }; + let dot = base_normal.dot(other_normal); + let Some(base_inward) = (-(other_normal - base_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(other_inward) = (-(base_normal - other_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(start) = source.vertices.get(edge.start) else { + return Vec::new(); + }; + let Some(end) = source.vertices.get(edge.end) else { + return Vec::new(); + }; + let midpoint = + (glam::DVec3::from_array(start.point) + glam::DVec3::from_array(end.point)) * 0.5; + [ + (GRIP_CHAMFER_DISTANCE1, value.base_distance, base_inward), + (GRIP_CHAMFER_DISTANCE2, value.other_distance, other_inward), + ] + .into_iter() + .filter(|(_, distance, _)| distance.is_finite() && *distance > 0.0) + .map(|(id, distance, axis)| { + grip( + id, + midpoint + axis * distance, + GripShape::Square, + Some(axis), + ) + }) + .collect() +} + pub fn primitive_grips( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -3093,6 +3198,9 @@ pub fn primitive_grips( let Some(operation) = document.solid_history_operation(handle) else { return Vec::new(); }; + if let SolidHistoryOperation::Chamfer(value) = operation { + return chamfer_distance_grips(document, handle, value); + } let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3713,3 +3821,21 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } + +pub fn chamfer_op( + edges: Vec, + base_face: i32, + base_distance: f64, + other_distance: f64, +) -> SolidHistoryOperation { + SolidHistoryOperation::Chamfer(SolidHistoryChamfer { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + base_distance, + other_distance, + edges, + base_face, + ..SolidHistoryChamfer::default() + }) +} diff --git a/src/scene/modify.rs b/src/scene/modify.rs index f84605425..552be4332 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -802,11 +802,39 @@ impl Scene { true } + pub fn append_solid_history( + &mut self, + handle: Handle, + operation: acadrust::objects::SolidHistoryOperation, + ) -> bool { + let previous = self + .document + .solid_history_graph(handle) + .map(|graph| graph.nodes) + .unwrap_or_default(); + self.record_solid_history_before(handle); + let Some(graph) = self.document.append_solid_history(handle, operation) else { + return false; + }; + for node in graph.nodes { + if !previous.contains(&node) { + self.record_undo_object_before(node, None); + } + } + self.sync_solid_reference_point(handle); + true + } + pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document - .solid_history_operation(handle) - .and_then(crate::scene::model::solid_history::reference_point); + .solid_history_operations(handle) + .and_then(|operations| { + operations + .iter() + .rev() + .find_map(crate::scene::model::solid_history::reference_point) + }); let Some(reference) = reference else { return; }; @@ -851,7 +879,34 @@ impl Scene { ) -> Option { use acadrust::objects::SolidHistoryOperation; - match (self.document.get_entity(handle)?, operation) { + let entity = self.document.get_entity(handle)?; + if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { + let mut operations = self.document.solid_history_operations(handle)?; + if operations.len() > 1 + || matches!(operation, SolidHistoryOperation::Chamfer(_)) + { + let replacement_id = operation.base().map(|base| { + if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + } + })?; + let target = operations.iter_mut().find(|candidate| { + candidate.base().is_some_and(|base| { + let node_id = if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + }; + node_id == replacement_id + }) + })?; + *target = operation.clone(); + return cadkernel::acis::rebuild_history(&operations).ok(); + } + } + match (entity, operation) { (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { cadkernel::acis::rebuild_extrusion_with_mode(value, true).ok() } @@ -868,6 +923,17 @@ impl Scene { ) -> Option> { use acadrust::objects::SolidHistoryOperation; + if self + .document + .solid_history_operations(handle) + .is_some_and(|operations| operations.len() > 1) + { + return matches!( + self.document.get_entity(handle), + Some(EntityType::Solid3D(_) | EntityType::Surface(_)) + ) + .then_some(None); + } match (self.document.get_entity(handle)?, operation) { (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { crate::scene::model::solid_history::extrusion_surface_data(value).map(Some) @@ -909,7 +975,7 @@ impl Scene { self.record_solid_history_before(handle); if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -985,7 +1051,7 @@ impl Scene { } if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -1023,7 +1089,7 @@ impl Scene { }; if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -1122,6 +1188,47 @@ impl Scene { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; + if matches!( + grip_id, + crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + | crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE2 + ) { + let definitions = { + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &operation else { + return false; + }; + crate::scene::model::solid_history::chamfer_distance_grips( + &self.document, + handle, + value, + ) + }; + let Some(definition) = definitions.into_iter().find(|grip| grip.id == grip_id) else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { + return false; + }; + let distance = if grip_id + == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + { + &mut value.base_distance + } else { + &mut value.other_distance + }; + *distance += change; + if !distance.is_finite() || *distance <= 1.0e-6 { + return false; + } + return self.preview_solid_history(handle, operation); + } if !crate::scene::model::solid_history::apply_primitive_grip( &mut operation, grip_id, @@ -1138,7 +1245,12 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { + let Some(mut operation) = + crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) + else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property( From 08891816bee828489cfc66635399ccf0662bd044 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 16:18:37 +0100 Subject: [PATCH 38/40] fix(render): highlight untinted analytical curves and guard uniform bind group - Fall back to WireModel::SELECTED when selected_tint is None (classic mode) so selected circles, arcs, and ellipses are not omitted from the highlight pass. - Explicitly rebind uniform_bind_group before drawing analytical curves in selection_xray pass. - Skip invisible wires (display_visible == false) during selection and hover wire extraction. --- src/scene/pipeline/gpu_tests.rs | 146 ++++++++++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 41 +++++---- 2 files changed, 171 insertions(+), 16 deletions(-) diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 7b7ab968f..6ff34c4d0 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -457,6 +457,37 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() { assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); assert!(pipeline.gpu_selected_wires.is_empty()); + // Verify classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None) + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_circles.len(), 1); + assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); + + // Verify hover highlight + let mut circle_hover = rustc_hash::FxHashSet::default(); + circle_hover.insert(handle); + let empty_selected = rustc_hash::FxHashSet::default(); + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &empty_selected, + &circle_hover, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_circles.len(), 1); + assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("test_target"), @@ -490,6 +521,121 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() { device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); } +#[test] +#[ignore = "requires a GPU adapter"] +fn test_selected_ellipse_overlay() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + + let mut ellipse_wire = WireModel::default(); + ellipse_wire.name = "ellipse_wire".into(); + ellipse_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center: [0.0, 0.0, 0.0], + major_axis: [10.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: 0.0, + end_param: std::f64::consts::TAU, + }); + + let handle = acadrust::Handle::new(200); + let mut selected_handles = rustc_hash::FxHashSet::default(); + selected_handles.insert(handle); + let hover_handles = rustc_hash::FxHashSet::default(); + + let mut index = rustc_hash::FxHashMap::default(); + index.insert(handle.value(), vec![0]); + pipeline.wire_handle_index = std::sync::Arc::new(index); + + let wires = vec![ellipse_wire]; + // Standard tint + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + Some([0.0, 1.0, 1.0, 1.0]), + ); + + assert_eq!(pipeline.gpu_selected_ellipses.len(), 1); + assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1); + assert!(pipeline.gpu_selected_wires.is_empty()); + + // Classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None) + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_ellipses.len(), 1); + assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1); + + // Hover highlight + let mut ellipse_hover = rustc_hash::FxHashSet::default(); + ellipse_hover.insert(handle); + let empty_selected = rustc_hash::FxHashSet::default(); + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &empty_selected, + &ellipse_hover, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_ellipses.len(), 1); + assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target_ellipse"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder_ellipse"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + #[test] #[ignore = "requires a GPU adapter"] fn test_pline_arc_switch_preview_and_render() { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 74313aa61..880464b12 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2641,6 +2641,9 @@ impl Pipeline { slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { + if !w.display_visible { + continue; + } selected_wires.push(w); } } @@ -2652,12 +2655,18 @@ impl Pipeline { slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { + if !w.display_visible { + continue; + } hover_wires.push(w); } } } } for wire in annotation_context_wires { + if !wire.display_visible { + continue; + } if wire.selected { selected_wires.push(wire); } else { @@ -2665,14 +2674,13 @@ impl Pipeline { } } let mut selected_circles: Vec = Vec::new(); - if let Some(tint) = selected_tint { - for &wire in &selected_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { - for mut inst in insts { - inst.color = tint; - selected_circles.push(inst); - } + let circle_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED); + for &wire in &selected_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + for mut inst in insts { + inst.color = circle_sel_tint; + selected_circles.push(inst); } } } @@ -2697,14 +2705,13 @@ impl Pipeline { }; let mut selected_ellipses: Vec = Vec::new(); - if let Some(tint) = selected_tint { - for &wire in &selected_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { - for mut inst in insts { - inst.color = tint; - selected_ellipses.push(inst); - } + let ellipse_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED); + for &wire in &selected_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + for mut inst in insts { + inst.color = ellipse_sel_tint; + selected_ellipses.push(inst); } } } @@ -4854,6 +4861,7 @@ impl Pipeline { } else { &self.circle_xray_pipeline }); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); for cg in &self.gpu_selected_circles { if cg.instance_count > 0 { pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); @@ -4867,6 +4875,7 @@ impl Pipeline { } else { &self.ellipse_xray_pipeline }); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); for eg in &self.gpu_selected_ellipses { if eg.instance_count > 0 { pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); From fd3ddc1608e2fc3ee94ccae80020bd40f94e6959 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 19:02:08 +0300 Subject: [PATCH 39/40] Finish Ramox edge and slice integration --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/ocs_doc_api/Cargo.toml | 4 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/bg-BG/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/cs-CZ/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/de-DE/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/el-GR/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/es-ES/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/fi-FI/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/fr-FR/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/hi-IN/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/hu-HU/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/it-IT/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/ja-JP/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/ko-KR/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/nl-NL/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/pl-PL/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/pt-BR/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/ru-RU/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/zh-CN/opencadstudio.ftl | 50 +++++++++++++++++++++++-- locales/zh-TW/opencadstudio.ftl | 50 +++++++++++++++++++++++-- src/scene/convert/acis_export.rs | 51 ++++++++++++++++++++++++++ 26 files changed, 954 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea4352261..232c0c982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=36152bc31e2df0136335e15b54e51c3c19349c63#36152bc31e2df0136335e15b54e51c3c19349c63" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=fd1c321#fd1c3216c433ff67723e89c24d324ac5045cdeb3" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=37bdf263d85261982f6014b89262af2bf6eff144#37bdf263d85261982f6014b89262af2bf6eff144" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=509f2fc754f7f1754800e9e90e2cb8b16d204403#509f2fc754f7f1754800e9e90e2cb8b16d204403" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 245fa3e92..0d5728e23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "36152bc31e2df0136335e15b54e51c3c19349c63", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "37bdf263d85261982f6014b89262af2bf6eff144", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "509f2fc754f7f1754800e9e90e2cb8b16d204403", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index e7586427e..6446507b6 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -14,8 +14,8 @@ bincode = "1.3" thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "36152bc31e2df0136335e15b54e51c3c19349c63", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "37bdf263d85261982f6014b89262af2bf6eff144", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "509f2fc754f7f1754800e9e90e2cb8b16d204403", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index ac88fe336..c02624b16 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c91a1c1de89a0fbc006dc360ddb0251ac8c207d1", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c91a1c1de89a0fbc006dc360ddb0251ac8c207d1", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a700c08c3..69952e90b 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c91a1c1de89a0fbc006dc360ddb0251ac8c207d1", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 89cea10e6..8046fe494 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index d66f73249..0a39768ce 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -777,7 +777,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: تعذر تحديد نطاق المجسم. .slice-cut-at-ax-value-kept-the-half = SLICE: تم القطع عند __ocs_fmt_0__=__ocs_fmt_1__، وأُبقي النصف __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: حدد مجسمًا واحدًا بالضبط أُنشئ في هذه الجلسة. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: لا يقطع المستوى المجسم على الجانب المحتفظ به. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: لا يقطع المستوى المجسم على الجانب المحتفظ به. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: تم تمرير منحنى عبر __ocs_fmt_0__ نقطة. .splinefit-need-at-least-3-points = SPLINEFIT: يلزم 3 نقاط على الأقل. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: حدد خطًا متعددًا لتمرير منحنى عبره. @@ -2207,7 +2220,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE محور مستوى القطع [X / Y / Z] (أضف TOP/BOTTOM بالكتابة): .slice-offset-along-x = SLICE الإزاحة على X: .slice-offset-along-y = SLICE الإزاحة على Y: - .slice-offset-along-z = SLICE الإزاحة على Z: + .slice-offset-along-z = SLICE الإزاحة على Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID حدد النقطة الأولى: .solid-specify-fourth-point = SOLID حدد النقطة الرابعة: .solid-specify-second-point = SOLID حدد النقطة الثانية: @@ -3197,7 +3230,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = إهليلجي - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 78562a4d1..6e7a791ec 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: обхватът на тялото не можа да бъде определен. .slice-cut-at-ax-value-kept-the-half = SLICE: разрязано при __ocs_fmt_0__=__ocs_fmt_1__, запазена е __ocs_fmt_2__ половина. .slice-select-exactly-one-solid-created-this-se = SLICE: избери точно едно тяло, създадено в тази сесия. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: равнината не пресича тялото откъм запазената страна. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: равнината не пресича тялото откъм запазената страна. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: сплайн прекаран през __ocs_fmt_0__ точки. .splinefit-need-at-least-3-points = SPLINEFIT: нужни са поне 3 точки. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: избери полилиния, през която да се прекара сплайн. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE ос на режещата равнина [X / Y / Z] (добави TOP/BOTTOM чрез въвеждане): .slice-offset-along-x = SLICE отместване по X: .slice-offset-along-y = SLICE отместване по Y: - .slice-offset-along-z = SLICE отместване по Z: + .slice-offset-along-z = SLICE отместване по Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Посочи първа точка: .solid-specify-fourth-point = SOLID Посочи четвърта точка: .solid-specify-second-point = SOLID Посочи втора точка: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Елиптичен - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 3dbc60ece..fe39582e9 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: Nelze určit rozsah tělesa. .slice-cut-at-ax-value-kept-the-half = SLICE: řez na __ocs_fmt_0__=__ocs_fmt_1__, zachování poloviny __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: vyberte přesně jedno těleso vytvořené v této relaci. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: rovina neprotíná těleso na držené straně. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: rovina neprotíná těleso na držené straně. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: proložení spline přes body __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: potřebujete alespoň 3 body. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: vyberte křivku pro proložení spline. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE osa řezné roviny [X / Y / Z] (přidejte TOP/BOTTOM zadáním): .slice-offset-along-x = Odsazení SLICE podél X: .slice-offset-along-y = Odsazení SLICE podél Y: - .slice-offset-along-z = Odsazení SLICE podél Z: + .slice-offset-along-z = Odsazení SLICE podél Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Zadejte první bod: .solid-specify-fourth-point = SOLID Zadejte čtvrtý bod: .solid-specify-second-point = SOLID Zadejte druhý bod: @@ -3182,7 +3215,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptický - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 32a5a2c40..e3f676f6a 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: konnte die Ausdehnung des Volumenkörper nicht bestimmen. .slice-cut-at-ax-value-kept-the-half = SLICE: geschnitten bei __ocs_fmt_0__=__ocs_fmt_1__, die Hälfte von __ocs_fmt_2__ beibehalten. .slice-select-exactly-one-solid-created-this-se = SLICE: Wählen Sie genau einen erstellten Volumenkörper. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Volumenkörper auf der gehaltenen Seite nicht. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Volumenkörper auf der gehaltenen Seite nicht. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: Passen Sie einen Spline durch __ocs_fmt_0__ Punkte. .splinefit-need-at-least-3-points = SPLINEFIT: mindestens 3 Punkte werden benötigt. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: Wählen Sie eine Polylinie, um eine Spline einzupassen. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE Schnittebenenachse [X / Y / Z] (fügen Sie TOP/BOTTOM durch Eingabe hinzu): .slice-offset-along-x = SLICE versetzt entlang X: .slice-offset-along-y = SLICE längs Y versetzt: - .slice-offset-along-z = SLICE Versetzt entlang Z: + .slice-offset-along-z = SLICE Versetzt entlang Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Geben Sie den ersten Punkt an: .solid-specify-fourth-point = SOLID Vierter Punkt angeben: .solid-specify-second-point = SOLID Zweiter Punkt angeben: @@ -3183,7 +3216,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptisch - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 1298bc4fa..5dba873a3 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -766,7 +766,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: δεν ήταν δυνατός ο προσδιορισμός της έκτασης του στερεού. .slice-cut-at-ax-value-kept-the-half = SLICE: τομή στο __ocs_fmt_0__=__ocs_fmt_1__, διατηρήθηκε το __ocs_fmt_2__ ήμισυ. .slice-select-exactly-one-solid-created-this-se = SLICE: επιλέξτε ακριβώς ένα στερεό που δημιουργήθηκε σε αυτή τη συνεδρία. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: το επίπεδο δεν τέμνει το στερεό στην πλευρά που διατηρείται. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: το επίπεδο δεν τέμνει το στερεό στην πλευρά που διατηρείται. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: προσαρμογή spline μέσα από __ocs_fmt_0__ σημεία. .splinefit-need-at-least-3-points = SPLINEFIT: απαιτούνται τουλάχιστον 3 σημεία. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: επιλέξτε μια πολυγραμμή για προσαρμογή spline. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE άξονας επιπέδου τομής [X / Y / Z] (προσθέστε TOP/BOTTOM πληκτρολογώντας): .slice-offset-along-x = SLICE μετατόπιση κατά X: .slice-offset-along-y = SLICE μετατόπιση κατά Y: - .slice-offset-along-z = SLICE μετατόπιση κατά Z: + .slice-offset-along-z = SLICE μετατόπιση κατά Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Ορίστε πρώτο σημείο: .solid-specify-fourth-point = SOLID Ορίστε τέταρτο σημείο: .solid-specify-second-point = SOLID Ορίστε δεύτερο σημείο: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Ελλειπτικό - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 1c0f75e74..097d983ba 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: no se pudo determinar la extensión del sólido. .slice-cut-at-ax-value-kept-the-half = SLICE: corte en __ocs_fmt_0__=__ocs_fmt_1__, se conservó la mitad __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: seleccione exactamente un sólido creado en esta sesión. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: el plano no atraviesa el sólido en el lado conservado. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: el plano no atraviesa el sólido en el lado conservado. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: se ajustó una spline por __ocs_fmt_0__ puntos. .splinefit-need-at-least-3-points = SPLINEFIT: se necesitan al menos 3 puntos. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: seleccione una polilínea por la que ajustar una spline. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE eje del plano de corte [X / Y / Z] (escriba TOP/BOTTOM para añadirlo): .slice-offset-along-x = SLICE desfase a lo largo de X: .slice-offset-along-y = SLICE desfase a lo largo de Y: - .slice-offset-along-z = SLICE desfase a lo largo de Z: + .slice-offset-along-z = SLICE desfase a lo largo de Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Precise el primer punto: .solid-specify-fourth-point = SOLID Precise el cuarto punto: .solid-specify-second-point = SOLID Precise el segundo punto: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elíptico - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index e54f22873..a3c76dc17 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: kiinteän aineen laajuutta ei voitu määrittää. .slice-cut-at-ax-value-kept-the-half = SLICE: leikattu kohdasta __ocs_fmt_0__=__ocs_fmt_1__, __ocs_fmt_2__ puolet säilytetty. .slice-select-exactly-one-solid-created-this-se = SLICE: valitse täsmälleen yksi tämän istunnon luotu solid. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: kone ei ylitä kiinteää pintaa säilytetyllä puolella. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: kone ei ylitä kiinteää pintaa säilytetyllä puolella. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: sovita spline __ocs_fmt_0__-pisteiden läpi. .splinefit-need-at-least-3-points =SPLINEFIT: tarvitset vähintään 3 pistettä. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: valitse polyline sovittaaksesi splinin läpi. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE leikkaustason akseli [X / Y / Z] (lisää TOP/BOTTOM kirjoittamalla): .slice-offset-along-x = SLICE offset pitkin X: .slice-offset-along-y = SLICE offset pitkin Y: - .slice-offset-along-z = SLICE offset pitkin Z: + .slice-offset-along-z = SLICE offset pitkin Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Määritä ensimmäinen piste: .solid-specify-fourth-point = SOLID Määritä neljäs piste: .solid-specify-second-point = SOLID Määritä toinen piste: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptinen - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 1666ae44b..3e0fa73e0 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE : ne pouvait déterminer l'étendue du solide. .slice-cut-at-ax-value-kept-the-half = SLICE: coupé à __ocs_fmt_0__=__ocs_fmt_1__, maintenu la moitié __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE : sélectionnez exactement un solide créé cette session. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: le plan ne traverse pas le solide du côté retenu. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: le plan ne traverse pas le solide du côté retenu. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: adaptez une élingue à des points __ocs_fmt_0__. .splinefit-need-at-least-3-points = SPLINEFIT: nécessite au moins 3 points. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT : sélectionnez une polyligne pour l'adapter à une spline. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE axe du plan de coupe [X / Y / Z] (ajouter TOP/BOTTOM par typage): .slice-offset-along-x = SLICE décalage le long de X: .slice-offset-along-y = SLICE décalage le long de Y: - .slice-offset-along-z = SLICE décalage le long de Z: + .slice-offset-along-z = SLICE décalage le long de Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Préciser le premier point: .solid-specify-fourth-point = SOLID Préciser le quatrième point: .solid-specify-second-point = SOLID Préciser le deuxième point: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptique - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 1a9d1d2b2..ef77a707c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -753,7 +753,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: ठोस की सीमा निर्धारित नहीं कर सकता है। .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__ = __ocs_fmt_1__ में कटौती, __ocs_fmt_2__ आधा रखा। .slice-select-exactly-one-solid-created-this-se = SLICE: इस सत्र को ठीक से एक ठोस बनाया गया चुनें। - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: विमान को रखा पक्ष पर ठोस पार नहीं किया जाता है। + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: विमान को रखा पक्ष पर ठोस पार नहीं किया जाता है। + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ अंक के माध्यम से एक स्पैन फिट। .splinefit-need-at-least-3-points = SPLINEFIT: कम से कम 3 अंक की आवश्यकता है। .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: एक sline के माध्यम से फिट करने के लिए एक पॉलीलाइन का चयन करें। @@ -2183,7 +2196,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE कटिंग-प्लेन अक्ष [X / Y / Z] (Typing द्वारा शीर्ष/BOTTOM को शामिल किया गया): .slice-offset-along-x = SLICE X के साथ ऑफसेट: .slice-offset-along-y = SLICE वाई के साथ ऑफसेट: - .slice-offset-along-z = SLICE Z के साथ ऑफसेट: + .slice-offset-along-z = SLICE Z के साथ ऑफसेट: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID पहली बिंदु निर्दिष्ट करें: .solid-specify-fourth-point = SOLID चौथे बिंदु को निर्दिष्ट करें: .solid-specify-second-point = SOLID दूसरे बिंदु को निर्दिष्ट करें: @@ -3173,7 +3206,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = दीर्घवृत्तीय - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index fcbc9dce5..f1d65d13b 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: nem tudta meghatározni a szilárdtest kiterjedését. .slice-cut-at-ax-value-kept-the-half = SLICE: vágás a __ocs_fmt_0__=__ocs_fmt_1__ pontnál, a __ocs_fmt_2__ felét megtartotta. .slice-select-exactly-one-solid-created-this-se = SLICE: válasszon ki pontosan egy szilárdtestet, amelyet ebben a munkamenetben hozott létre. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: a sík nem keresztezi a szilárdtestet a megtartott oldalon. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: a sík nem keresztezi a szilárdtestet a megtartott oldalon. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: illesszen be egy spline-t a __ocs_fmt_0__ pontokon. .splinefit-need-at-least-3-points =SPLINEFIT: legalább 3 pont szükséges. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: válasszon egy vonalláncot a spline átillesztéséhez. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE vágósík tengelye [X / Y / Z] (adja hozzá a TOP/BOTTOM-t beírással): .slice-offset-along-x = SLICE eltolás a X mentén: .slice-offset-along-y = SLICE eltolás a Y mentén: - .slice-offset-along-z = SLICE eltolás a Z mentén: + .slice-offset-along-z = SLICE eltolás a Z mentén: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Adja meg az első pontot: .solid-specify-fourth-point = SOLID Adja meg a negyedik pontot: .solid-specify-second-point = SOLID Második pont megadása: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptikus - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index fb5be76da..581b0a6ec 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: impossibile determinare l'estensione del solido. .slice-cut-at-ax-value-kept-the-half = SLICE: tagliato a __ocs_fmt_0__=__ocs_fmt_1__, ha mantenuto la metà __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: seleziona esattamente un solido creato in questa sessione. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: il piano non attraversa il solido dal lato mantenuto. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: il piano non attraversa il solido dal lato mantenuto. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: adatta una spline attraverso i punti __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: servono almeno 3 punti. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: seleziona una polilinea attraverso la quale inserire una spline. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = Asse del piano di taglio SLICE [X / Y / Z] (aggiungere TOP/BOTTOM digitando): .slice-offset-along-x = Scostamento SLICE lungo X: .slice-offset-along-y = Scostamento SLICE lungo Y: - .slice-offset-along-z = Scostamento SLICE lungo Z: + .slice-offset-along-z = Scostamento SLICE lungo Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Specificare il primo punto: .solid-specify-fourth-point = SOLID Specificare il quarto punto: .solid-specify-second-point = SOLID Specificare il secondo punto: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Ellittico - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index ef8e5b9db..f43975364 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -755,7 +755,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: ソリッドの範囲を特定できませんでした。 .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__ で切断し、__ocs_fmt_2__ 側を保持しました。 .slice-select-exactly-one-solid-created-this-se = SLICE: このセッションで作成したソリッドを 1 つ選択してください。 - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 保持側で平面がソリッドと交差しません。 + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 保持側で平面がソリッドと交差しません。 + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ 点を通るスプラインを作成しました。 .splinefit-need-at-least-3-points = SPLINEFIT: 3 点以上必要です。 .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: スプラインを通すポリラインを選択してください。 @@ -2185,7 +2198,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 切断面の軸 [X / Y / Z](TOP/BOTTOM を追加可能): .slice-offset-along-x = SLICE X 方向オフセット: .slice-offset-along-y = SLICE Y 方向オフセット: - .slice-offset-along-z = SLICE Z 方向オフセット: + .slice-offset-along-z = SLICE Z 方向オフセット: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 1 点目を指定: .solid-specify-fourth-point = SOLID 4 点目を指定: .solid-specify-second-point = SOLID 2 点目を指定: @@ -3173,7 +3206,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 楕円 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index f2924828c..f3f85d334 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: 솔리드의 범위를 확인할 수 없습니다. .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__에서 절단하고 __ocs_fmt_2__ 절반을 유지합니다. .slice-select-exactly-one-solid-created-this-se = SLICE: 이번 세션에서 생성된 정확히 하나의 솔리드를 선택합니다. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 평면이 유지된 측면의 솔리드를 교차하지 않습니다. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 평면이 유지된 측면의 솔리드를 교차하지 않습니다. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ 점을 통해 스플라인을 맞춥니다. .splinefit-need-at-least-3-points =SPLINEFIT: 최소 3개의 포인트가 필요합니다. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: 스플라인에 맞게 폴리선을 선택합니다. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 절단 평면 축 [X / Y / Z] (입력하여 TOP/BOTTOM 추가): .slice-offset-along-x = X를 따라 SLICE 오프셋: .slice-offset-along-y = Y를 따라 SLICE 오프셋: - .slice-offset-along-z = Z를 따라 SLICE 오프셋: + .slice-offset-along-z = Z를 따라 SLICE 오프셋: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 첫 번째 점 지정: .solid-specify-fourth-point = SOLID 네 번째 점 지정: .solid-specify-second-point = SOLID 두 번째 점 지정: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 타원형 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 90970dca3..ce817e236 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -763,7 +763,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: kon de omvang van de vaste stof niet bepalen. .slice-cut-at-ax-value-kept-the-half = SLICE: knippen met __ocs_fmt_0__=__ocs_fmt_1__, de helft __ocs_fmt_2__ behouden. .slice-select-exactly-one-solid-created-this-se = SLICE: selecteer precies één vaste die deze sessie aangemaakt heeft. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: het vlak kruist de vaste stof niet aan de kant van de bewaarde zijde. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: het vlak kruist de vaste stof niet aan de kant van de bewaarde zijde. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: pas een spline door __ocs_fmt_0__ punten. .splinefit-need-at-least-3-points = SPLINEFIT: minimaal 3 punten nodig. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: selecteer een polylijn om een spline door te passen. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE snijvlakas [X / Y / Z] (voeg TOP/BOTTOM toe door te typen): .slice-offset-along-x = SLICE offset langs X: .slice-offset-along-y = SLICE offset langs Y: - .slice-offset-along-z = SLICE offset langs Z: + .slice-offset-along-z = SLICE offset langs Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Geef het eerste punt op: .solid-specify-fourth-point = SOLID Geef het vierde punt op: .solid-specify-second-point = SOLID Tweede punt specificeren: @@ -3183,7 +3216,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptisch - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 28cb72201..370f2eca9 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: nie można określić zasięgu bryły. .slice-cut-at-ax-value-kept-the-half = SLICE: cięcie w __ocs_fmt_0__=__ocs_fmt_1__, zachowanie połowy __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: wybierz dokładnie jedną bryłę utworzoną w tej sesji. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: płaszczyzna nie przecina bryły po zachowanej stronie. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: płaszczyzna nie przecina bryły po zachowanej stronie. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: dopasuj splajn przez punkty __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: potrzebne są co najmniej 3 punkty. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: wybierz polilinię, przez którą ma zostać dopasowany splajn. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = Oś płaszczyzny cięcia SLICE [X / Y / Z] (dodaj TOP/BOTTOM wpisując): .slice-offset-along-x = Przesunięcie SLICE wzdłuż X: .slice-offset-along-y = Przesunięcie SLICE wzdłuż Y: - .slice-offset-along-z = Przesunięcie SLICE wzdłuż Z: + .slice-offset-along-z = Przesunięcie SLICE wzdłuż Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Określ pierwszy punkt: .solid-specify-fourth-point = SOLID Określ czwarty punkt: .solid-specify-second-point = SOLID Określ drugi punkt: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptyczny - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 64f5c9416..2e938bf5e 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: não foi possível determinar a extensão do sólido. .slice-cut-at-ax-value-kept-the-half = SLICE: corte em __ocs_fmt_0__=__ocs_fmt_1__, metade __ocs_fmt_2__ mantida. .slice-select-exactly-one-solid-created-this-se = SLICE: selecione exatamente um sólido criado nesta sessão. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: o plano não atravessa o sólido no lado mantido. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: o plano não atravessa o sólido no lado mantido. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: spline ajustada através de __ocs_fmt_0__ pontos. .splinefit-need-at-least-3-points = SPLINEFIT: são necessários pelo menos 3 pontos. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: selecione uma polilinha através da qual ajustar uma spline. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE eixo do plano de corte [X / Y / Z] (digite TOP/BOTTOM para adicionar): .slice-offset-along-x = SLICE deslocamento ao longo de X: .slice-offset-along-y = SLICE deslocamento ao longo de Y: - .slice-offset-along-z = SLICE deslocamento ao longo de Z: + .slice-offset-along-z = SLICE deslocamento ao longo de Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Especifique o primeiro ponto: .solid-specify-fourth-point = SOLID Especifique o quarto ponto: .solid-specify-second-point = SOLID Especifique o segundo ponto: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elíptico - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index f0b563824..f7615b132 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -768,7 +768,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: не может определить степень твердого тела. .slice-cut-at-ax-value-kept-the-half = SLICE: вырезать на __ocs_fmt_0__=__ocs_fmt_1__, сохранить __ocs_fmt_2__ половину. .slice-select-exactly-one-solid-created-this-se = SLICE: выберите один твердотельный, созданный на этом сеансе. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: плоскость не пересекает твердую часть на удерживаемой стороне. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: плоскость не пересекает твердую часть на удерживаемой стороне. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: проведите сплин через точки __ocs_fmt_0__. .splinefit-need-at-least-3-points = SPLINEFIT: необходимо не менее 3 баллов. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: выберите полилин, чтобы соответствовать сплину. @@ -2198,7 +2211,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE ось режущей плоскости [X/Y/Z] (добавить TOP/BOTTOM путем набора текста): .slice-offset-along-x = SLICE Смещение вдоль X: .slice-offset-along-y = SLICE смещение вдоль Y: - .slice-offset-along-z = SLICE Смещение вдоль Z: + .slice-offset-along-z = SLICE Смещение вдоль Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Укажите первую точку: .solid-specify-fourth-point = SOLID Укажите четвертую точку: .solid-specify-second-point = SOLID Укажите вторую точку: @@ -3186,7 +3219,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Эллиптический - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index caf2c55c2..324259609 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -752,7 +752,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE:无法确定固体的大小。 .slice-cut-at-ax-value-kept-the-half = SLICE: 切到__ocs_fmt_0__=__ocs_fmt_1__, 保留__ocs_fmt_2__的一半。 .slice-select-exactly-one-solid-created-this-se = SLICE:选择一个创建此会话的正体 。 - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 飞机不会越过所保存的一侧的固体。 + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 飞机不会越过所保存的一侧的固体。 + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: 匹配一条通过__ocs_fmt_0__点的相片。 .splinefit-need-at-least-3-points = SPLINEFIT:至少需要3个点。 .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT:选择一条多线以适应通过的分线。 @@ -2182,7 +2195,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 剖切平面轴 [X / Y / Z](输入 TOP/BOTTOM 可追加): .slice-offset-along-x = SLICE 页:1 .slice-offset-along-y = SLICE 与 Y 相冲: - .slice-offset-along-z = SLICE 与 Z 相冲: + .slice-offset-along-z = SLICE 与 Z 相冲: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 指定第一点: .solid-specify-fourth-point = SOLID 指定第四点: .solid-specify-second-point = SOLID 指定第二点: @@ -3168,7 +3201,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 椭圆 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index ecefead67..340ca3c9f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE:無法確定固體的大小。 .slice-cut-at-ax-value-kept-the-half = SLICE: 切到__ocs_fmt_0__=__ocs_fmt_1__, 保留__ocs_fmt_2__的一半。 .slice-select-exactly-one-solid-created-this-se = SLICE:選擇一個建立此會話的正體 。 - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 平面不會越過所儲存的一側的固體。 + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 平面不會越過所儲存的一側的固體。 + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: 匹配一條通過__ocs_fmt_0__點的相片。 .splinefit-need-at-least-3-points = SPLINEFIT:至少需要3個點。 .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT:選擇一條多線以適應通過的分線。 @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 剖切平面軸 [X / Y / Z](輸入 TOP/BOTTOM 可追加): .slice-offset-along-x = SLICE 頁:1 .slice-offset-along-y = SLICE 與 Y 相衝: - .slice-offset-along-z = SLICE 與 Z 相衝: + .slice-offset-along-z = SLICE 與 Z 相衝: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 指定第一點: .solid-specify-fourth-point = SOLID 指定第四點: .solid-specify-second-point = SOLID 指定第二點: @@ -3180,7 +3213,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 橢圓 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/src/scene/convert/acis_export.rs b/src/scene/convert/acis_export.rs index 48e3730ce..42875bb84 100644 --- a/src/scene/convert/acis_export.rs +++ b/src/scene/convert/acis_export.rs @@ -21,3 +21,54 @@ pub fn solid_to_sat(body: &Body) -> Option { let binary = SabWriter::write(&document); valid(&SabReader::read(&binary).ok()?).then_some(document) } + +#[cfg(test)] +mod tests { + use super::*; + use cadkernel::geom2d::{Arc, Curve}; + use cadkernel::space::Plane; + + fn circle_section(z: f64, radius: f64) -> (Plane, Vec) { + let curves = (0..4) + .map(|part| { + let start = std::f64::consts::FRAC_PI_2 * part as f64; + Curve::Arc(Arc { + centre: [0.0, 0.0], + radius, + start_angle: start, + end_angle: start + std::f64::consts::FRAC_PI_2, + }) + }) + .collect(); + ( + Plane::from_axes([0.0, 0.0, z], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + curves, + ) + } + + #[test] + fn a_curved_loft_round_trips_through_text_and_binary_acis() { + let body = cadkernel::brep::loft(&[ + circle_section(0.0, 5.0), + circle_section(10.0, 2.0), + ]) + .unwrap(); + assert!(body.validate().is_empty()); + + let mut document = SatDocument::new(); + cadkernel::acis::append(&body, &mut document).unwrap(); + let text = document.to_sat_string(); + let parsed = SatDocument::parse(&text).unwrap(); + let (restored, text_loss) = cadkernel::acis::lift(&parsed); + assert!(text_loss.is_empty(), "{text_loss:?}"); + assert_eq!(restored.len(), 1); + assert!(restored[0].validate().is_empty()); + + let binary = SabWriter::write(&parsed); + let parsed_binary = SabReader::read(&binary).unwrap(); + let (restored, binary_loss) = cadkernel::acis::lift(&parsed_binary); + assert!(binary_loss.is_empty(), "{binary_loss:?}"); + assert_eq!(restored.len(), 1); + assert!(restored[0].validate().is_empty()); + } +} From f611ceb6380221d806c6095c478aeeab8a99b82a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 19:34:39 +0300 Subject: [PATCH 40/40] Update CAD dependency revisions --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- crates/ocs_doc_api/Cargo.toml | 4 ++-- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 232c0c982..902c37361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=fd1c321#fd1c3216c433ff67723e89c24d324ac5045cdeb3" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=44cde65#44cde6532ea89a5b3169e5eb829cff44e51a3120" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=509f2fc754f7f1754800e9e90e2cb8b16d204403#509f2fc754f7f1754800e9e90e2cb8b16d204403" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=256b7c829b6c1d64cd762568902c035eb6af7c31#256b7c829b6c1d64cd762568902c035eb6af7c31" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 0d5728e23..fe2ac68bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "509f2fc754f7f1754800e9e90e2cb8b16d204403", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "256b7c829b6c1d64cd762568902c035eb6af7c31", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 6446507b6..a8848b619 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -14,8 +14,8 @@ bincode = "1.3" thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "509f2fc754f7f1754800e9e90e2cb8b16d204403", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "256b7c829b6c1d64cd762568902c035eb6af7c31", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index c02624b16..4df55bf53 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 69952e90b..80b884411 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 8046fe494..7b7b0537c 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "fd1c321", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "44cde65", features = ["serde"] }