From 4eb656e94609b4017ad5eba68a51f0ca6acf809c Mon Sep 17 00:00:00 2001 From: juan Date: Sun, 6 Sep 2026 19:57:24 -0300 Subject: [PATCH 01/71] add-paper-sizes --- src/app/update/file.rs | 54 +++++++++++++++++++++++------------------- src/io/paper_sizes.rs | 14 ++++++++++- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b1d169170..da8e77fc7 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -133,6 +133,9 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f "A1" => Some(PaperSize::A1), "A0" => Some(PaperSize::A0), "A4" => Some(PaperSize::A4), + "Letter" => Some(PaperSize::Letter), + "Legal" => Some(PaperSize::Legal), + "Tabloid" => Some(PaperSize::Tabloid), _ => None, }; if let Some(paper) = standard { @@ -2576,7 +2579,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let pick = Task::perform( async move { let mut dlg = crate::sys::file_dialog() - .set_title(crate::t!("Save Drawing As").as_ref()) + .set_title("Save Drawing As") .set_file_name(default_name) .add_filter(filter_label, &[filter_ext]); if let Some(dir) = seed_dir { @@ -2934,8 +2937,8 @@ pub(super) fn on_open_file(&mut self) -> Task { plot_style.as_ref(), render_options, ) - .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) - .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) + .map(|_| format!("Exported: {}", worker_path.display())) + .map_err(|e| format!("Export failed: {e}")) }; self.run_plot_work(self.plot_dialog.background, false, work) } @@ -2978,12 +2981,12 @@ pub(super) fn on_open_file(&mut self) -> Task { plot_style.as_ref(), render_options, ) - .map(|_| {crate::tf!( + .map(|_| {format!( "Plotted window to {}", worker_path .file_name().unwrap_or_default().to_string_lossy() - ).into_owned() - }).map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) + ) + }).map_err(|e| format!("Plot failed: {e}")) }; self.run_plot_work(self.plot_dialog.background, false, work) } @@ -3071,7 +3074,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let page_setup = self.tabs[i] .scene .plot_settings_for(&name) - .ok_or_else(|| crate::tf!("Layout '{name}' has no page setup.").into_owned())?; + .ok_or_else(|| format!("Layout '{name}' has no page setup."))?; { let scene = &mut self.tabs[i].scene; scene.current_layout = name.clone(); @@ -3092,10 +3095,10 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.restore_saved_camera(); } if dialog.style_missing && dialog.apply_plot_styles { - return Err(crate::tf!( + return Err(format!( "Layout '{name}' plot style table '{}' is not loaded.", dialog.style_name - ).into_owned()); + )); } let plot_style = self.dialog_plot_style(&dialog); let params = match dialog.area.as_str() { @@ -3135,7 +3138,7 @@ pub(super) fn on_open_file(&mut self) -> Task { rotation_deg, scale, clip, - ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; + ) = params.ok_or_else(|| format!("Layout '{name}' plot area is empty."))?; ( std::sync::Arc::new(wires), hatches, @@ -3219,8 +3222,8 @@ pub(super) fn on_open_file(&mut self) -> Task { &worker_path, None, ) - .map(|_| crate::tf!("Exported {} layouts to {}", pages.len(), worker_path.display()).into_owned()) - .map_err(|error| crate::tf!("Export failed: {error}").into_owned()) + .map(|_| format!("Exported {} layouts to {}", pages.len(), worker_path.display())) + .map_err(|error| format!("Export failed: {error}")) }; self.run_print_all_work(dialog.background, work) } @@ -3269,8 +3272,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .and_then(|_| { crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) }) - .map(|printer| crate::tf!("Sent {} layouts to printer: {printer}", pages.len()).into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + .map(|printer| format!("Sent {} layouts to printer: {printer}", pages.len())) + .map_err(|error| format!("Print failed: {error}")) }; self.run_print_all_work(true, work) } @@ -4285,7 +4288,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); - if !matches!(paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0") + if !matches!(paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid") && !ps.paper_size.is_empty() { paper = ps.paper_size.clone(); @@ -4389,6 +4392,9 @@ pub(super) fn on_open_file(&mut self) -> Task { "A2" => PaperSize::A2, "A1" => PaperSize::A1, "A0" => PaperSize::A0, + "Letter" => PaperSize::Letter, + "Legal" => PaperSize::Legal, + "Tabloid" => PaperSize::Tabloid, _ => PaperSize::A4, }; let orient = if d.orientation == "Portrait" { @@ -4480,7 +4486,7 @@ pub(super) fn on_open_file(&mut self) -> Task { plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) - .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) }; return self.run_plot_work(d.background, true, work); } @@ -4497,8 +4503,8 @@ pub(super) fn on_open_file(&mut self) -> Task { plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) + .map(|printer| format!("Sent to printer: {printer}")) + .map_err(|e| format!("Print failed: {e}")) }; return self.run_plot_work(true, false, work); } @@ -4525,7 +4531,7 @@ pub(super) fn on_open_file(&mut self) -> Task { plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) - .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) }; return self.run_plot_work(d.background, true, work); } @@ -4543,8 +4549,8 @@ pub(super) fn on_open_file(&mut self) -> Task { wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, plot_style, opts, )) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + .map(|printer| format!("Sent to printer: {printer}")) + .map_err(|error| format!("Print failed: {error}")) }; self.run_plot_work(true, false, work) } @@ -4860,10 +4866,10 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::perform( async move { let dialog = crate::sys::file_dialog() - .set_title(crate::t!("Save Plot Style Table").as_ref()) + .set_title("Save Plot Style Table") .set_file_name(&default_name) - .add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]); + .add_filter("Plot Style Files", &["ctb", "CTB"]) + .add_filter("All Files", &["*"]); #[cfg(not(target_arch = "wasm32"))] let dialog = match crate::io::plot_style::ensure_plot_styles_dir() { Ok(dir) => dialog.set_directory(dir), diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs index 8fcbefde8..9eaa1200d 100644 --- a/src/io/paper_sizes.rs +++ b/src/io/paper_sizes.rs @@ -7,15 +7,21 @@ pub enum PaperSize { A2, A1, A0, + Letter, + Legal, + Tabloid, } impl PaperSize { - pub const ALL: [PaperSize; 5] = [ + pub const ALL: [PaperSize; 8] = [ PaperSize::A4, PaperSize::A3, PaperSize::A2, PaperSize::A1, PaperSize::A0, + PaperSize::Letter, + PaperSize::Legal, + PaperSize::Tabloid, ]; pub fn label(self) -> &'static str { @@ -25,6 +31,9 @@ impl PaperSize { PaperSize::A2 => "A2", PaperSize::A1 => "A1", PaperSize::A0 => "A0", + PaperSize::Letter => "Letter", + PaperSize::Legal => "Legal", + PaperSize::Tabloid => "Tabloid", } } @@ -36,6 +45,9 @@ impl PaperSize { PaperSize::A2 => (420.0, 594.0), PaperSize::A1 => (594.0, 841.0), PaperSize::A0 => (841.0, 1189.0), + PaperSize::Letter => (215.9, 279.4), + PaperSize::Legal => (215.9, 355.6), + PaperSize::Tabloid => (279.4, 431.8), } } } From bd6447d930567a97f31be45b5f57ba17ab78de90 Mon Sep 17 00:00:00 2001 From: codallos Date: Sun, 6 Sep 2026 19:50:55 -0600 Subject: [PATCH 02/71] Inset glyph atlas UVs to prevent texture bleeding --- src/scene/text/sdf_atlas.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index b9bdd56e6..c192389f1 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -391,8 +391,13 @@ impl GlyphAtlas { let (fw, fh) = (self.width as f32, self.height as f32); Some(AtlasEntry { - uv_min: [ox as f32 / fw, oy as f32 / fh], - uv_max: [(ox + tile.w) as f32 / fw, (oy + tile.h) as f32 / fh], + // Sample the centers of the edge texels rather than the tile boundaries. + // This prevents bilinear filtering from bleeding into neighbouring tiles. + uv_min: [(ox as f32 + 0.5) / fw, (oy as f32 + 0.5) / fh], + uv_max: [ + (ox as f32 + tile.w as f32 - 0.5) / fw, + (oy as f32 + tile.h as f32 - 0.5) / fh, + ], plane_min: tile.plane_min, plane_max: tile.plane_max, advance: tile.advance, From 91e94dc693e8ec968efe8132753e9a7c5c9e0425 Mon Sep 17 00:00:00 2001 From: codallos Date: Sun, 6 Sep 2026 20:38:56 -0600 Subject: [PATCH 03/71] Simplify glyph atlas UV calculation --- src/scene/text/sdf_atlas.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index c192389f1..dc87acdfa 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -395,8 +395,8 @@ impl GlyphAtlas { // This prevents bilinear filtering from bleeding into neighbouring tiles. uv_min: [(ox as f32 + 0.5) / fw, (oy as f32 + 0.5) / fh], uv_max: [ - (ox as f32 + tile.w as f32 - 0.5) / fw, - (oy as f32 + tile.h as f32 - 0.5) / fh, + ((ox + tile.w) as f32 - 0.5) / fw, + ((oy + tile.h) as f32 - 0.5) / fh, ], plane_min: tile.plane_min, plane_max: tile.plane_max, From 822a7138c53b12d29b734bc0f8b7b800e97864d0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:40:14 +0300 Subject: [PATCH 04/71] Match visible LOFT command options --- src/modules/insert/solid3d_cmds.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index f068bc8c4..e5cfce0fb 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2211,7 +2211,7 @@ impl LoftCommand { fn option_prompt(&self) -> String { match self.state.step { LoftStep::Sections => format!( - "{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode/Undo] (Enter to finish)"), + "{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish)"), self.state.sections.len(), ), LoftStep::Join => format!( @@ -2224,9 +2224,9 @@ impl LoftCommand { LoftStep::Options => { let (start, end) = self.point_ends(); if start || end { - t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude/Mode/Undo] :").into_owned() + t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] :").into_owned() } else { - t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Mode/Undo] :").into_owned() + t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings] :").into_owned() } } LoftStep::Guides => format!("{} ({}):", t!("LOFT Select guide curves or [Undo] (Enter to finish)"), self.state.guides.len()), @@ -2272,7 +2272,7 @@ impl CadCommand for LoftCommand { } fn options(&self) -> Vec { let mut options = match self.state.step { - LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE")], LoftStep::Join | LoftStep::Guides => vec![CmdOption::enter("Done")], LoftStep::Mode => vec![CmdOption::new("Solid", "SOLID"), CmdOption::new("Surface", "SURFACE")], LoftStep::Options => { @@ -2282,7 +2282,6 @@ impl CadCommand for LoftCommand { choices.push(CmdOption::new("Continuity", "CONTINUITY")); choices.push(CmdOption::new("Bulge magnitude", "BULGE")); } - choices.push(CmdOption::new("Mode", "MODE")); choices } LoftStep::Settings => { @@ -2308,7 +2307,9 @@ impl CadCommand for LoftCommand { LoftStep::StartContinuity | LoftStep::EndContinuity => vec![CmdOption::new("G0", "G0"), CmdOption::new("G1", "G1")], _ => Vec::new(), }; - options.push(CmdOption::new("Undo", "UNDO")); + if !matches!(self.state.step, LoftStep::Sections | LoftStep::Options) { + options.push(CmdOption::new("Undo", "UNDO")); + } options } fn needs_entity_pick(&self) -> bool { From a59ea4799042e4b199c3feaba1e8658c3fb30dbb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:27:03 +0300 Subject: [PATCH 05/71] Complete UNION command behavior --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/commands/draw.rs | 109 ++++++---- src/app/model_ops.rs | 332 +++++++++++++++++++++++++++++- src/app/properties.rs | 54 +++-- src/modules/draw/select.rs | 27 ++- src/scene/convert/solid3d_tess.rs | 4 + src/scene/entity.rs | 3 + src/scene/model/solid_history.rs | 36 ++++ src/scene/model/solid_model.rs | 7 +- 11 files changed, 503 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d4975fe2..287dcca72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,7 +882,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6a5e6fc2822f6af6b2611cd605b44437b35158f0#6a5e6fc2822f6af6b2611cd605b44437b35158f0" +source = "git+https://github.com/ramox81/cadkernel.git?rev=328b55a9a45bc2118526a49156bcb94e46f10444#328b55a9a45bc2118526a49156bcb94e46f10444" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 5ccd655d6..ffc5091e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,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 = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "328b55a9a45bc2118526a49156bcb94e46f10444", 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 973bd0459..0b7944852 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 = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "328b55a9a45bc2118526a49156bcb94e46f10444", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 5090140ef..71a281e8b 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -972,32 +972,46 @@ impl OpenCADStudio { } // ── Solid booleans ───────────────────────────────────────────── - "UNION" | "INTERSECT" => { + "UNION" => { use crate::modules::model::boolean_cmd::BoolOp; - if let Some(op) = BoolOp::from_id(cmd) { - let solid_count = { - let scene = &self.tabs[i].scene; - 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(_)) - ) - }) - .take(2) - .count() - }; - if solid_count < 2 { - use crate::modules::draw::select::SelectObjectsCommand; - let selection = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&selection.prompt()); - self.tabs[i].active_cmd = Some(Box::new(selection)); - } else { - return Some(self.solid_boolean(op)); - } + if self.union_ready() { + return Some(self.solid_boolean(BoolOp::Union)); + } + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::plain("UNION", "UNIONAPPLY"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } + + "UNIONAPPLY" => { + use crate::modules::model::boolean_cmd::BoolOp; + return Some(self.solid_boolean(BoolOp::Union)); + } + + "INTERSECT" => { + use crate::modules::model::boolean_cmd::BoolOp; + let solid_count = { + let scene = &self.tabs[i].scene; + 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(_)) + ) + }) + .take(2) + .count() + }; + if solid_count < 2 { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + return Some(self.solid_boolean(BoolOp::Intersect)); } } @@ -1069,9 +1083,9 @@ impl OpenCADStudio { // REGION — convert selected closed boundaries (closed polylines / // circles) into Region entities (one wire loop each). "REGION" | "REG" => { - use acadrust::entities::{Region, Wire}; + use acadrust::entities::Region; use acadrust::types::Vector3; - let mut loops: Vec> = Vec::new(); + let mut regions = Vec::new(); for (_, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, @@ -1079,32 +1093,39 @@ impl OpenCADStudio { if pl.is_closed && pl.vertices.len() >= 3 ) || matches!(e, acadrust::EntityType::Circle(_)); if supported { - let Some(curve) = crate::entities::curve::entity_curve(e) else { + let Some((plane, loops, true)) = + crate::scene::model::presspull_model::profile_geometry(e) + else { continue; }; - loops.push( - crate::entities::curve::curve_points(&curve) - .into_iter() - .map(|point| Vector3::new(point[0], point[1], point[2])) - .collect(), + let Some(body) = cadkernel::brep::planar_region(plane, &loops) else { + continue; + }; + let mut region = Region::new(); + region.point_of_reference = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], ); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); } } - if loops.is_empty() { + if regions.is_empty() { self.command_line .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); } else { self.push_undo_snapshot(i, "REGION"); - let count = loops.len(); - for pts in loops { - let mut w = Wire::new(); - let first = pts.first().copied().unwrap_or(Vector3::new(0.0, 0.0, 0.0)); - w.points = pts; - let mut r = Region::new(); - r.point_of_reference = first; - r.wires = vec![w]; - r.common.layer = self.tabs[i].active_layer.clone(); - self.tabs[i].scene.add_entity(acadrust::EntityType::Region(r)); + let count = regions.len(); + let mut created = Vec::with_capacity(count); + for (region, body) in regions { + let handle = self.add_region_model(region, body); + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + return Some(iced::Task::none()); + } + created.push(handle); } self.tabs[i].dirty = true; self.command_line diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 38c287d23..a98e7a6df 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,7 +1,9 @@ // Kernel B-rep solid modelling and exact ACIS persistence. use acadrust::{ - entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, + entities::{EntityCommon, Region, Solid3D, Surface, SurfaceKind}, + objects::SolidHistoryOperation, + EntityType, Handle, }; use cadkernel::brep::Body; use iced::Task; @@ -11,19 +13,112 @@ use crate::modules::model::boolean_cmd::BoolOp; use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; +#[derive(Clone, Copy, PartialEq, Eq)] +enum UnionEntityKind { + Solid, + Region, + Surface, +} + +impl UnionEntityKind { + fn from_entity(entity: &EntityType) -> Option { + Some(match entity { + EntityType::Solid3D(_) => Self::Solid, + EntityType::Region(_) => Self::Region, + EntityType::Surface(_) => Self::Surface, + _ => return None, + }) + } +} + +struct UnionGroup { + kind: UnionEntityKind, + handles: Vec, +} + +struct PreparedUnion { + kind: UnionEntityKind, + handles: Vec, + body: Body, + common: EntityCommon, +} + +fn inherited_common(source: &EntityCommon) -> EntityCommon { + let mut result = EntityCommon::new(); + result.layer = source.layer.clone(); + result.color = source.color; + result.line_weight = source.line_weight; + result.linetype = source.linetype.clone(); + result.linetype_handle = source.linetype_handle; + result.linetype_scale = source.linetype_scale; + result.transparency = source.transparency; + result.color_name = source.color_name.clone(); + result.invisible = source.invisible; + result.color_book_handle = source.color_book_handle; + result.full_visual_style_handle = source.full_visual_style_handle; + result.face_visual_style_handle = source.face_visual_style_handle; + result.edge_visual_style_handle = source.edge_visual_style_handle; + result.material_flags = source.material_flags; + result.material_handle = source.material_handle; + result.shadow_flags = source.shadow_flags; + result.plotstyle_flags = source.plotstyle_flags; + result.plotstyle_handle = source.plotstyle_handle; + result +} + +fn union_bodies( + kind: UnionEntityKind, + bodies: Vec, +) -> Result { + if kind == UnionEntityKind::Solid { + let mut operands = bodies.into_iter(); + let mut result = operands + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for operand in operands { + result = solid_model::boolean_result(Bool::Union, &result, &operand)?; + } + return Ok(result); + } + let references = bodies.iter().collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + cadkernel::brep::union_planar_regions(&bodies, tolerance) +} + impl super::OpenCADStudio { /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( + &mut self, + entity: EntityType, + solid: Body, + history: SolidHistoryOperation, + ) -> Handle { + self.add_solid_model_inner(entity, solid, history, true) + } + + fn add_solid_model_preserving_style( + &mut self, + entity: EntityType, + solid: Body, + history: SolidHistoryOperation, + ) -> Handle { + self.add_solid_model_inner(entity, solid, history, false) + } + + fn add_solid_model_inner( &mut self, mut entity: EntityType, solid: Body, history: SolidHistoryOperation, + apply_creation_style: bool, ) -> Handle { let i = self.active_tab; let EntityType::Solid3D(inner) = &mut entity else { return Handle::NULL; }; - inner.common.plotstyle_flags = 2; + if apply_creation_style { + inner.common.plotstyle_flags = 2; + } inner.wires = solid_model::edge_wires(&solid); let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) else { @@ -32,7 +127,12 @@ impl super::OpenCADStudio { return Handle::NULL; }; inner.set_sat_document(&document); - let Some(handle) = self.commit_entity_handle(entity) else { + let handle = if apply_creation_style { + self.commit_entity_handle(entity) + } else { + self.commit_entity_handle_preserve_style(entity) + }; + let Some(handle) = handle else { return Handle::NULL; }; let require_complete = matches!(history, SolidHistoryOperation::Loft(_)); @@ -51,15 +151,34 @@ impl super::OpenCADStudio { /// Add an open sheet body as a persistent Surface entity and register its /// exact B-rep for shaded and wireframe display. pub(super) fn add_surface_model( + &mut self, + entity: EntityType, + surface: Body, + ) -> Handle { + self.add_surface_model_inner(entity, surface, true) + } + + fn add_surface_model_preserving_style( + &mut self, + entity: EntityType, + surface: Body, + ) -> Handle { + self.add_surface_model_inner(entity, surface, false) + } + + fn add_surface_model_inner( &mut self, mut entity: EntityType, surface: Body, + apply_creation_style: bool, ) -> Handle { let i = self.active_tab; let EntityType::Surface(inner) = &mut entity else { return Handle::NULL; }; - inner.common.plotstyle_flags = 2; + if apply_creation_style { + inner.common.plotstyle_flags = 2; + } inner.wires = solid_model::edge_wires(&surface); let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&surface) else { @@ -68,7 +187,12 @@ impl super::OpenCADStudio { return Handle::NULL; }; inner.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); - let Some(handle) = self.commit_entity_handle(entity) else { + let handle = if apply_creation_style { + self.commit_entity_handle(entity) + } else { + self.commit_entity_handle_preserve_style(entity) + }; + let Some(handle) = handle else { return Handle::NULL; }; let require_complete = self.tabs[i].scene.document.get_entity(handle).is_some_and(|entity| @@ -81,6 +205,44 @@ impl super::OpenCADStudio { handle } + pub(super) fn add_region_model(&mut self, region: Region, body: Body) -> Handle { + self.add_region_model_inner(region, body, false) + } + + fn add_region_model_preserving_style(&mut self, region: Region, body: Body) -> Handle { + self.add_region_model_inner(region, body, true) + } + + fn add_region_model_inner( + &mut self, + mut region: Region, + body: Body, + preserve_style: bool, + ) -> Handle { + let i = self.active_tab; + region.wires = solid_model::edge_wires(&body); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { + self.command_line + .push_error(crate::t!("The region could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + region.set_sat_document(&document); + let entity = EntityType::Region(region); + let handle = if preserve_style { + self.commit_entity_handle_preserve_style(entity) + } else { + self.commit_entity_handle(entity) + }; + let Some(handle) = handle else { + return Handle::NULL; + }; + if !self.tabs[i].scene.register_solid_model(handle, body) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; let mut handles: Vec = self.tabs[i] @@ -100,6 +262,38 @@ impl super::OpenCADStudio { handles } + fn selected_union_groups(&self) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + let mut groups: Vec = Vec::new(); + for handle in scene.selected_handles_in_order() { + if scene.is_layer_locked(handle) { + continue; + } + let Some(kind) = scene + .document + .get_entity(handle) + .and_then(UnionEntityKind::from_entity) + else { + continue; + }; + if let Some(group) = groups.iter_mut().find(|group| group.kind == kind) { + group.handles.push(handle); + } else { + groups.push(UnionGroup { + kind, + handles: vec![handle], + }); + } + } + groups + } + + pub(super) fn union_ready(&self) -> bool { + self.selected_union_groups() + .iter() + .any(|group| group.handles.len() >= 2) + } + fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { let i = self.active_tab; let Some(document) = @@ -197,6 +391,9 @@ impl super::OpenCADStudio { /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + if op == BoolOp::Union { + return self.union_selected_entities(); + } let i = self.active_tab; let handles = self.selected_solid_handles(); if handles.len() < 2 { @@ -243,6 +440,131 @@ impl super::OpenCADStudio { Task::none() } + fn union_selected_entities(&mut self) -> Task { + let i = self.active_tab; + let mut groups = self.selected_union_groups(); + let handles = groups + .iter() + .flat_map(|group| group.handles.iter().copied()) + .collect::>(); + self.tabs[i].scene.restore_solid_models(&handles); + for group in &mut groups { + group + .handles + .retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + } + groups.retain(|group| group.handles.len() >= 2); + if groups.is_empty() { + self.command_line.push_error( + crate::t!("UNION: select at least two solids, Regions, or planar Surfaces of the same type.") + .as_ref(), + ); + return Task::none(); + } + + let mut prepared = Vec::with_capacity(groups.len()); + for group in groups { + let bodies = group + .handles + .iter() + .filter_map(|handle| self.tabs[i].scene.solid_models.get(handle).cloned()) + .collect::>(); + let result = match union_bodies(group.kind, bodies) { + Ok(result) => result, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("UNION: the selected geometry includes an unsupported surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("UNION: the selected coincident geometry is ambiguous.").as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("UNION: the selected topology could not be closed safely.").as_ref(), + ); + return Task::none(); + } + }; + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line.push_error( + crate::t!("The UNION result could not be encoded as ACIS.").as_ref(), + ); + return Task::none(); + } + let Some(source) = group + .handles + .first() + .and_then(|handle| self.tabs[i].scene.document.get_entity(*handle)) + else { + return Task::none(); + }; + prepared.push(PreparedUnion { + kind: group.kind, + handles: group.handles, + body: result, + common: inherited_common(source.common()), + }); + } + + let consumed = prepared + .iter() + .flat_map(|group| group.handles.iter().copied()) + .collect::>(); + self.push_undo_snapshot(i, "UNION"); + let mut created = Vec::with_capacity(prepared.len()); + for group in prepared { + let handle = match group.kind { + UnionEntityKind::Solid => { + let history = solid_history::brep_op(&group.body); + let mut entity = Solid3D::new(); + entity.common = group.common; + self.add_solid_model_preserving_style( + EntityType::Solid3D(entity), + group.body, + history, + ) + } + UnionEntityKind::Region => { + let mut entity = Region::new(); + entity.common = group.common; + self.add_region_model_preserving_style(entity, group.body) + } + UnionEntityKind::Surface => { + let mut entity = Surface::new(SurfaceKind::Generic); + entity.common = group.common; + self.add_surface_model_preserving_style( + EntityType::Surface(entity), + group.body, + ) + } + }; + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + return Task::none(); + } + created.push(handle); + } + + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + for handle in &created { + self.tabs[i].scene.select_entity(*handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("UNION: created %{count} result object(s).", count = created.len()).as_ref(), + ); + Task::none() + } + pub(super) fn solid_subtract( &mut self, bases: &[Handle], diff --git a/src/app/properties.rs b/src/app/properties.rs index 55e291820..1785a8d2c 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2591,7 +2591,7 @@ impl OpenCADStudio { &mut self, entity: acadrust::EntityType, ) -> Option { - self.commit_entity_handle_with_policies(entity, false, false) + self.commit_entity_handle_with_policies(entity, false, false, false) } pub(super) fn commit_entity_handle_with_dimension_policy( @@ -2603,6 +2603,7 @@ impl OpenCADStudio { entity, preserve_dimension_layer_and_style, false, + false, ) } @@ -2610,7 +2611,14 @@ impl OpenCADStudio { &mut self, entity: acadrust::EntityType, ) -> Option { - self.commit_entity_handle_with_policies(entity, false, true) + self.commit_entity_handle_with_policies(entity, false, true, false) + } + + pub(super) fn commit_entity_handle_preserve_style( + &mut self, + entity: acadrust::EntityType, + ) -> Option { + self.commit_entity_handle_with_policies(entity, false, true, true) } fn commit_entity_handle_with_policies( @@ -2618,6 +2626,7 @@ impl OpenCADStudio { mut entity: acadrust::EntityType, preserve_dimension_layer_and_style: bool, preserve_entity_layer: bool, + preserve_entity_style: bool, ) -> Option { let i = self.active_tab; let tracks_dimension_chain = matches!( @@ -2735,25 +2744,32 @@ impl OpenCADStudio { } } - crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); - crate::scene::view::dispatch::apply_common_prop( - &mut entity, - "linetype", - &self.ribbon.active_linetype.clone(), - ); - crate::scene::view::dispatch::apply_line_weight(&mut entity, self.ribbon.active_lineweight); - // CELTSCALE (header.current_entity_linetype_scale): new entities - // pick up the document's saved per-entity linetype scale. The user - // can override per entity later via the properties panel. - let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; - if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { - entity.common_mut().linetype_scale = celtscale; + if !preserve_entity_style { + crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + // CELTSCALE (header.current_entity_linetype_scale): new entities + // pick up the document's saved per-entity linetype scale. The user + // can override per entity later via the properties panel. + let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } } - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); + if !preserve_entity_style { + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + } if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = (inherited_dimension, &mut entity) { diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 9ff9efe08..8116fbc07 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -17,6 +17,7 @@ use crate::modules::draw::fence::FencePick; use crate::scene::model::wire_model::WireModel; pub struct SelectObjectsCommand { + prompt_cmd: String, pending_cmd: String, /// Selection accumulated so far (kept in sync with the scene selection by /// each `on_selection_complete` call). Applied on Enter when `commit_on_enter`. @@ -24,6 +25,8 @@ pub struct SelectObjectsCommand { /// When true, gathering continues until Enter / right-click commits the set. /// When false, the first completed selection action fires immediately. commit_on_enter: bool, + /// Whether the generic selection methods are shown as command buttons. + show_options: bool, /// A Fence / WPolygon / CPolygon path being picked point by point. While /// one is under way the host must send clicks here as points rather than /// treating them as picks, which is what `is_selection_gathering` reports. @@ -37,9 +40,25 @@ impl SelectObjectsCommand { /// Standard selection set: accumulate picks, apply on Enter / right-click. pub fn new(pending_cmd: &str) -> Self { Self { + prompt_cmd: pending_cmd.to_string(), pending_cmd: pending_cmd.to_string(), handles: Vec::new(), commit_on_enter: true, + show_options: true, + pick: None, + pick_crossing: true, + } + } + + /// Plain gather prompt with no generic selection-method buttons. The + /// visible command name may differ from the private apply command. + pub fn plain(prompt_cmd: &str, pending_cmd: &str) -> Self { + Self { + prompt_cmd: prompt_cmd.to_string(), + pending_cmd: pending_cmd.to_string(), + handles: Vec::new(), + commit_on_enter: true, + show_options: false, pick: None, pick_crossing: true, } @@ -49,9 +68,11 @@ impl SelectObjectsCommand { /// immediately, with no Enter (used by commands that act on one object). pub fn instant(pending_cmd: &str) -> Self { Self { + prompt_cmd: pending_cmd.to_string(), pending_cmd: pending_cmd.to_string(), handles: Vec::new(), commit_on_enter: false, + show_options: false, pick: None, pick_crossing: true, } @@ -82,12 +103,12 @@ impl CadCommand for SelectObjectsCommand { if self.commit_on_enter && !self.handles.is_empty() { t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", - cmd = self.pending_cmd, + cmd = self.prompt_cmd, count = self.handles.len() ) .into_owned() } else { - t!("%{cmd} Select objects:", cmd = self.pending_cmd).into_owned() + t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() } } @@ -108,7 +129,7 @@ impl CadCommand for SelectObjectsCommand { // they work typed as well — these buttons only surface them, which is what // the on-screen keyboard-less case needs. fn options(&self) -> Vec { - if self.commit_on_enter { + if self.commit_on_enter && self.show_options { vec![ CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Crossing").as_ref(), "C"), diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index ebfa32df8..3e3376bfa 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -163,6 +163,10 @@ pub fn kernel_surface_body(surface: &Surface) -> Option { kernel_acis_body(&surface.acis_data) } +pub fn kernel_region_body(region: &Region) -> Option { + kernel_acis_body(®ion.acis_data) +} + fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { let sat = parse_acis( || acis.parse(), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 1fe0317b4..89faaa20e 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -605,6 +605,9 @@ impl Scene { Some(EntityType::Surface(surface)) => { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } + Some(EntityType::Region(region)) => { + crate::scene::convert::solid3d_tess::kernel_region_body(region) + } _ => None, })?; Some((handle, body)) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 37293ac70..7976fd17d 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -975,6 +975,41 @@ fn cone_properties( ] } +fn brep_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Vec { + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + vec![PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: if show_history { "Yes" } else { "No" }.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string()) + }, + }, + ], + }] +} + fn cylinder_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -1411,6 +1446,7 @@ pub fn primitive_properties( extrusion_properties(document, handle, value) } SolidHistoryOperation::Revolve(value) => revolve_properties(document, handle, value), + SolidHistoryOperation::Brep(_) => brep_properties(document, handle), _ => Vec::new(), } } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 24969c146..d6e8c6d9a 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -365,13 +365,18 @@ pub enum Bool { /// missing, and passing that on unchanged is the point: a half-done boolean /// looks finished. pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { + boolean_result(op, a, b).ok() +} + +/// Combine two solids while retaining the kernel's exact refusal reason. +pub fn boolean_result(op: Bool, a: &Body, b: &Body) -> Result { let how = match op { Bool::Union => brep::Operation::Union, Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; let tolerance = brep::operation_tolerance(&[a, b]); - brep::combine(a.clone(), b.clone(), how, tolerance).ok() + brep::combine(a.clone(), b.clone(), how, tolerance) } // ── Tessellation ──────────────────────────────────────────────────────────── From 5857ffa2bd14e8aa99eedac1c6e89fcdfb364ed0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:02:59 +0300 Subject: [PATCH 06/71] Align UNION solid properties palette --- src/app/properties.rs | 20 ++++++++++---------- src/scene/model/solid_history.rs | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index 1785a8d2c..ad055f125 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -440,15 +440,15 @@ impl OpenCADStudio { let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane); let entity = &display_entity; let group_names = self.tabs[i].scene.group_names_for_entity(handle); - let specialized_primitive = - crate::scene::model::solid_history::has_specialized_primitive_properties( + let compact_solid = + crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, handle, ); let mut sections = dispatch::properties_sectioned(handle, entity, &text_style_names); - if specialized_primitive { - retain_specialized_primitive_sections(&mut sections); + if compact_solid { + retain_compact_solid_sections(&mut sections); } sections.extend( crate::scene::model::solid_history::primitive_properties( @@ -636,7 +636,7 @@ impl OpenCADStudio { } } - if !specialized_primitive && matches!( + if !compact_solid && matches!( entity, acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Region(_) @@ -710,7 +710,7 @@ impl OpenCADStudio { } } - if !specialized_primitive { + if !compact_solid { sections.extend(crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, @@ -2068,8 +2068,8 @@ impl OpenCADStudio { }); } } - if specialized_primitive { - retain_specialized_primitive_sections(&mut sections); + if compact_solid { + retain_compact_solid_sections(&mut sections); } let title = match entity { acadrust::EntityType::Insert(ins) => { @@ -2166,7 +2166,7 @@ impl OpenCADStudio { .collect(); let mut sections = aggregate_sections(&local_refs, &text_style_names); if local_refs.iter().all(|(handle, _)| { - crate::scene::model::solid_history::has_specialized_primitive_properties( + crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, *handle, ) @@ -3077,7 +3077,7 @@ fn aggregate_solid_history_sections( merged } -fn retain_specialized_primitive_sections( +fn retain_compact_solid_sections( sections: &mut Vec, ) { sections.iter_mut().for_each(|section| { diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 7976fd17d..3130cb3bc 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -214,6 +214,21 @@ pub fn has_specialized_primitive_properties( ) } +/// 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 +/// Solid History palette rather than the internal ACIS/cache diagnostics. +pub fn has_compact_solid_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> bool { + has_specialized_primitive_properties(document, handle) + || matches!( + document.solid_history_operation(handle), + Some(SolidHistoryOperation::Brep(_)) + ) +} + pub fn reference_point(operation: &SolidHistoryOperation) -> Option { match operation { SolidHistoryOperation::Box(value) | SolidHistoryOperation::Wedge(value) => world_point( From 04a9a1b0a0f7d78c5c87ab25548accf4e4c1f14e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:58:31 +0300 Subject: [PATCH 07/71] Complete SUBTRACT behavior --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/command_driver.rs | 8 +- src/app/commands/draw.rs | 28 ++- src/app/model_ops.rs | 412 +++++++++++++++++++++++++++---- src/command.rs | 1 + src/entities/mesh.rs | 61 +++++ src/modules/model/boolean_cmd.rs | 89 ++++++- src/scene/model/solid_history.rs | 26 +- 10 files changed, 562 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 287dcca72..795add4b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,7 +882,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=328b55a9a45bc2118526a49156bcb94e46f10444#328b55a9a45bc2118526a49156bcb94e46f10444" +source = "git+https://github.com/ramox81/cadkernel.git?rev=2162f5cce085929966047b8c24c7e0da83c26910#2162f5cce085929966047b8c24c7e0da83c26910" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index ffc5091e2..4599c5f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,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 = "328b55a9a45bc2118526a49156bcb94e46f10444", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "2162f5cce085929966047b8c24c7e0da83c26910", 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 0b7944852..2ac2a9851 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 = "328b55a9a45bc2118526a49156bcb94e46f10444", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "2162f5cce085929966047b8c24c7e0da83c26910", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e2dd3c6e5..0b74e15fd 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4115,8 +4115,12 @@ impl OpenCADStudio { return task; } - CmdResult::SolidSubtract { bases, cutters } => { - let task = self.solid_subtract(&bases, &cutters); + CmdResult::SolidSubtract { + bases, + cutters, + convert_meshes, + } => { + let task = self.solid_subtract(&bases, &cutters, convert_meshes); 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/draw.rs b/src/app/commands/draw.rs index 71a281e8b..ad42b31a9 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1017,22 +1017,40 @@ impl OpenCADStudio { "SUBTRACT" => { use crate::modules::model::boolean_cmd::SubtractCommand; - let bases = { + let (bases, bases_have_mesh) = { let scene = &self.tabs[i].scene; - scene + let bases = 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(_)) + Some( + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Region(_) + | acadrust::EntityType::Surface(_) + | acadrust::EntityType::Mesh(_) + | acadrust::EntityType::PolygonMesh(_) + | acadrust::EntityType::PolyfaceMesh(_) + ) ) }) - .collect() + .collect::>(); + let bases_have_mesh = bases.iter().any(|handle| { + matches!( + scene.document.get_entity(*handle), + Some( + acadrust::EntityType::Mesh(_) + | acadrust::EntityType::PolygonMesh(_) + | acadrust::EntityType::PolyfaceMesh(_) + ) + ) + }); + (bases, bases_have_mesh) }; self.tabs[i].scene.deselect_all(); - let subtract = SubtractCommand::new(bases); + let subtract = SubtractCommand::new(bases, bases_have_mesh); self.command_line.push_info(&subtract.prompt()); self.tabs[i].active_cmd = Some(Box::new(subtract)); } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a98e7a6df..60ec88977 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,7 +1,9 @@ // Kernel B-rep solid modelling and exact ACIS persistence. use acadrust::{ - entities::{EntityCommon, Region, Solid3D, Surface, SurfaceKind}, + entities::{ + AcisData, EntityCommon, Region, Solid3D, Surface, SurfaceData, SurfaceKind, Wire, + }, objects::SolidHistoryOperation, EntityType, Handle, }; @@ -31,6 +33,17 @@ impl UnionEntityKind { } } +fn subtract_entity_kind(entity: &EntityType, convert_meshes: bool) -> Option { + UnionEntityKind::from_entity(entity).or_else(|| { + (convert_meshes + && matches!( + entity, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + )) + .then_some(UnionEntityKind::Solid) + }) +} + struct UnionGroup { kind: UnionEntityKind, handles: Vec, @@ -43,6 +56,25 @@ struct PreparedUnion { common: EntityCommon, } +struct SubtractGroup { + kind: UnionEntityKind, + plane: Option, + bases: Vec, + cutters: Vec, +} + +type PreparedSolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + +struct PreparedSubtract { + bases: Vec, + cutters: Vec, + result: Option<(Handle, EntityType, Body, PreparedSolidDisplay)>, +} + fn inherited_common(source: &EntityCommon) -> EntityCommon { let mut result = EntityCommon::new(); result.layer = source.layer.clone(); @@ -85,6 +117,111 @@ fn union_bodies( cadkernel::brep::union_planar_regions(&bodies, tolerance) } +fn planar_body_plane(body: &Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let first_normal = glam::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; + }; + let Some(normal) = profile.plane.normal() else { + return false; + }; + first_normal.dot(glam::DVec3::from_array(normal)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) + }) + .then_some(first) +} + +fn coplanar_bodies(plane: cadkernel::space::Plane, body: &Body) -> bool { + let Some(other) = planar_body_plane(body) else { + return false; + }; + let Some(one_normal) = plane.normal().map(glam::DVec3::from_array) else { + return false; + }; + let Some(other_normal) = other.normal().map(glam::DVec3::from_array) else { + return false; + }; + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + one_normal.dot(other_normal).abs() >= 1.0 - 1e-9 + && plane + .distance_to(other.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) +} + +fn subtract_bodies( + kind: UnionEntityKind, + plane: Option, + bases: Vec, + cutters: Vec, +) -> Result { + if kind != UnionEntityKind::Solid && plane.is_none() { + return Err(cadkernel::brep::Snag::NoClosedForm); + } + if kind != UnionEntityKind::Solid && plane.is_some() { + let references = bases.iter().chain(&cutters).collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + return cadkernel::brep::subtract_planar_regions(&bases, &cutters, tolerance); + } + + let mut bases = bases.into_iter(); + let mut result = bases + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for base in bases { + result = solid_model::boolean_result(Bool::Union, &result, &base)?; + } + for cutter in cutters { + result = solid_model::boolean_result(Bool::Subtract, &result, &cutter)?; + } + Ok(result) +} + +fn entity_with_subtract_body(mut source: EntityType, body: &Body) -> Option { + let document = crate::scene::convert::acis_export::solid_to_sat(body)?; + let wires = solid_model::edge_wires(body); + if matches!( + &source, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + ) { + let mut solid = Solid3D::new(); + solid.common = source.common().clone(); + source = EntityType::Solid3D(solid); + } + match &mut source { + EntityType::Solid3D(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Region(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + 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) +} + impl super::OpenCADStudio { /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( @@ -569,73 +706,262 @@ impl super::OpenCADStudio { &mut self, bases: &[Handle], cutters: &[Handle], + convert_meshes: bool, ) -> Task { let i = self.active_tab; - let valid_solid = |handle: &Handle| { + let valid_operand = |handle: &Handle| { !self.tabs[i].scene.is_layer_locked(*handle) - && matches!( - self.tabs[i].scene.document.get_entity(*handle), - Some(EntityType::Solid3D(_)) - ) + && self.tabs[i] + .scene + .document + .get_entity(*handle) + .and_then(|entity| subtract_entity_kind(entity, convert_meshes)) + .is_some() }; - let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_solid).collect(); - let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_solid).collect(); + let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_operand).collect(); + let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_operand).collect(); cutter_handles.retain(|handle| !base_handles.contains(handle)); let mut operands = base_handles.clone(); operands.extend(cutter_handles.iter().copied()); self.tabs[i].scene.restore_solid_models(&operands); - base_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); - cutter_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + let mut operand_bodies = std::collections::HashMap::::new(); + for handle in &operands { + if let Some(body) = self.tabs[i].scene.solid_models.get(handle).cloned() { + operand_bodies.insert(*handle, body); + continue; + } + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) else { + continue; + }; + if convert_meshes + && matches!( + entity, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + ) + { + let Some(body) = crate::entities::mesh::closed_mesh_body(entity) else { + self.command_line.push_error( + crate::t!("SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + operand_bodies.insert(*handle, body); + } + } + base_handles.retain(|handle| operand_bodies.contains_key(handle)); + cutter_handles.retain(|handle| operand_bodies.contains_key(handle)); if base_handles.is_empty() || cutter_handles.is_empty() { self.command_line.push_error( - crate::t!("SUBTRACT: select at least one base solid and one cutter solid.") + crate::t!("SUBTRACT: select at least one base and one cutter Solid, Region, or Surface.") .as_ref(), ); return Task::none(); } - let mut result = self.tabs[i].scene.solid_models[&base_handles[0]].clone(); - for handle in &base_handles[1..] { - let operand = &self.tabs[i].scene.solid_models[handle]; - let Some(combined) = solid_model::boolean(Bool::Union, &result, operand) else { - self.command_line.push_error( - crate::t!("SUBTRACT failed while combining the base solids.").as_ref(), - ); - return Task::none(); - }; - result = combined; + let kind_for = |handle: Handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| subtract_entity_kind(entity, convert_meshes)) + }; + let mut groups = Vec::::new(); + for kind in [ + UnionEntityKind::Solid, + UnionEntityKind::Region, + UnionEntityKind::Surface, + ] { + let kind_bases = base_handles + .iter() + .copied() + .filter(|handle| kind_for(*handle) == Some(kind)) + .collect::>(); + if kind_bases.is_empty() { + continue; + } + if kind == UnionEntityKind::Solid { + let kind_cutters = cutter_handles + .iter() + .copied() + .filter(|handle| kind_for(*handle) == Some(kind)) + .collect::>(); + if !kind_cutters.is_empty() { + groups.push(SubtractGroup { + kind, + plane: None, + bases: kind_bases, + cutters: kind_cutters, + }); + } + continue; + } + + for handle in kind_bases { + let body = &operand_bodies[&handle]; + let plane = planar_body_plane(body); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && match (group.plane, plane) { + (Some(group_plane), Some(_)) => coplanar_bodies(group_plane, body), + (None, None) => true, + _ => false, + } + }) { + group.bases.push(handle); + } else { + groups.push(SubtractGroup { + kind, + plane, + bases: vec![handle], + cutters: Vec::new(), + }); + } + } + for handle in cutter_handles + .iter() + .copied() + .filter(|handle| kind_for(*handle) == Some(kind)) + { + let body = &operand_bodies[&handle]; + let plane = planar_body_plane(body); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && match (group.plane, plane) { + (Some(group_plane), Some(_)) => coplanar_bodies(group_plane, body), + (None, None) => true, + _ => false, + } + }) { + group.cutters.push(handle); + } + } } - for handle in &cutter_handles { - let operand = &self.tabs[i].scene.solid_models[handle]; - let Some(difference) = solid_model::boolean(Bool::Subtract, &result, operand) else { - self.command_line.push_error( - crate::t!("SUBTRACT failed while removing the selected solids.").as_ref(), - ); - return Task::none(); - }; - result = difference; - } - if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { - self.command_line - .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); + groups.retain(|group| !group.bases.is_empty() && !group.cutters.is_empty()); + if groups.is_empty() { + self.command_line.push_error( + crate::t!("SUBTRACT: no compatible base and cutter types or planes were selected.") + .as_ref(), + ); return Task::none(); } - operands = base_handles; - operands.extend(cutter_handles); + let mut prepared = Vec::with_capacity(groups.len()); + for group in groups { + let base_bodies = group + .bases + .iter() + .map(|handle| operand_bodies[handle].clone()) + .collect::>(); + let cutter_bodies = group + .cutters + .iter() + .map(|handle| operand_bodies[handle].clone()) + .collect::>(); + let result = match subtract_bodies(group.kind, group.plane, base_bodies, cutter_bodies) { + Ok(result) => result, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("SUBTRACT: the selected geometry includes an unsupported surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("SUBTRACT: the selected coincident geometry is ambiguous.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("SUBTRACT: the selected topology could not be cut safely.") + .as_ref(), + ); + return Task::none(); + } + }; + let staged_result = if result.faces.is_empty() { + None + } else { + let retained = group.bases[0]; + let Some(source) = self.tabs[i].scene.document.get_entity(retained).cloned() else { + return Task::none(); + }; + let Some(entity) = entity_with_subtract_body(source, &result) else { + self.command_line.push_error( + crate::t!("The SUBTRACT result could not be encoded as ACIS.").as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(retained, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("The SUBTRACT result could not be displayed completely. The original objects were retained.") + .as_ref(), + ); + return Task::none(); + }; + Some((retained, entity, result, display)) + }; + prepared.push(PreparedSubtract { + bases: group.bases, + cutters: group.cutters, + result: staged_result, + }); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; self.push_undo_snapshot(i, "SUBTRACT"); - self.tabs[i].scene.erase_entities(&operands); - let mut entity = Solid3D::new(); - entity.wires = solid_model::edge_wires(&result); - let history = solid_history::brep_op(&result); - let handle = self.add_solid_model(EntityType::Solid3D(entity), result, history); + let mut retained = Vec::new(); + let mut consumed = Vec::new(); + for group in prepared { + if let Some((handle, entity, body, display)) = group.result { + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line.push_error( + crate::t!("SUBTRACT: the retained base object could not be updated.").as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) + { + let history = solid_history::brep_op(&body); + self.tabs[i].scene.create_solid_history(handle, history); + } + self.tabs[i] + .scene + .register_prepared_solid_model(handle, body, display); + retained.push(handle); + consumed.extend(group.bases.into_iter().skip(1)); + } else { + consumed.extend(group.bases); + } + consumed.extend(group.cutters); + } + self.tabs[i].scene.erase_entities(&consumed); 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(); + self.command_line.push_output( + crate::tf!( + "SUBTRACT: created %{count} result object(s).", + count = retained.len() + ) + .as_ref(), + ); Task::none() } diff --git a/src/command.rs b/src/command.rs index 12222ac40..25ccfb027 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1557,6 +1557,7 @@ pub enum CmdResult { SolidSubtract { bases: Vec, cutters: Vec, + convert_meshes: bool, }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs index af3775653..e11ebed08 100644 --- a/src/entities/mesh.rs +++ b/src/entities/mesh.rs @@ -991,6 +991,67 @@ fn display_mesh(mesh: &Mesh) -> RefinedMesh { refined } +/// Convert a closed indexed mesh entity to an exact planar-faced B-rep. +/// Open, non-manifold, non-planar, and degenerate meshes return `None` so a +/// modelling command can leave the source untouched. +pub(crate) fn closed_mesh_body(entity: &acadrust::EntityType) -> Option { + match entity { + acadrust::EntityType::Mesh(mesh) => { + let base = base_refined_mesh(mesh); + cadkernel::brep::make::faceted_solid(&base.vertices, &base.faces) + } + acadrust::EntityType::PolygonMesh(mesh) => { + if !mesh.is_closed_m() || !mesh.is_closed_n() { + return None; + } + let m = mesh.m_vertex_count.max(0) as usize; + let n = mesh.n_vertex_count.max(0) as usize; + let vertices = mesh + .vertices + .iter() + .take(m.saturating_mul(n)) + .map(|vertex| v3(&vertex.location)) + .collect::>(); + if m < 2 || n < 2 || vertices.len() != m.saturating_mul(n) { + return None; + } + let mut faces = Vec::with_capacity(m.saturating_mul(n)); + for row in 0..m { + for column in 0..n { + faces.push(vec![ + row * n + column, + ((row + 1) % m) * n + column, + ((row + 1) % m) * n + (column + 1) % n, + row * n + (column + 1) % n, + ]); + } + } + cadkernel::brep::make::faceted_solid(&vertices, &faces) + } + acadrust::EntityType::PolyfaceMesh(mesh) => { + let vertices = mesh + .vertices + .iter() + .map(|vertex| v3(&vertex.location)) + .collect::>(); + let faces = mesh + .faces + .iter() + .filter_map(|face| { + let indices = [face.index1, face.index2, face.index3, face.index4] + .into_iter() + .filter(|index| *index != 0) + .map(|index| (index.unsigned_abs() as usize).checked_sub(1)) + .collect::>>()?; + (indices.len() >= 3).then_some(indices) + }) + .collect::>(); + cadkernel::brep::make::faceted_solid(&vertices, &faces) + } + _ => None, + } +} + fn face_triangle_indices( vertices: &[[f64; 3]], faces: &[Vec], diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index ff607b815..d24567b3c 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -25,16 +25,20 @@ impl BoolOp { enum SubtractStep { Bases, Cutters, + ConvertMeshes, } pub struct SubtractCommand { step: SubtractStep, bases: Vec, + cutters: Vec, selected: Vec, + bases_have_mesh: bool, + selected_has_mesh: bool, } impl SubtractCommand { - pub fn new(bases: Vec) -> Self { + pub fn new(bases: Vec, bases_have_mesh: bool) -> Self { let step = if bases.is_empty() { SubtractStep::Bases } else { @@ -43,7 +47,18 @@ impl SubtractCommand { Self { step, bases, + cutters: Vec::new(), selected: Vec::new(), + bases_have_mesh, + selected_has_mesh: false, + } + } + + fn finish(&mut self, convert_meshes: bool) -> CmdResult { + CmdResult::SolidSubtract { + bases: std::mem::take(&mut self.bases), + cutters: std::mem::take(&mut self.cutters), + convert_meshes, } } } @@ -55,11 +70,26 @@ impl CadCommand for SubtractCommand { fn prompt(&self) -> String { match self.step { - SubtractStep::Bases => crate::t!("SUBTRACT Select base solids, then press Enter:") + SubtractStep::Bases => crate::t!("SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter:") .into_owned(), SubtractStep::Cutters => { - crate::t!("SUBTRACT Select solids to subtract, then press Enter:").into_owned() + crate::t!("SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter:").into_owned() } + SubtractStep::ConvertMeshes => crate::t!( + "SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] :" + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + if matches!(self.step, SubtractStep::ConvertMeshes) { + vec![ + crate::command::CmdOption::new(crate::t!("Yes").as_ref(), "Y"), + crate::command::CmdOption::new(crate::t!("No").as_ref(), "N"), + ] + } else { + Vec::new() } } @@ -68,31 +98,72 @@ impl CadCommand for SubtractCommand { } fn on_enter(&mut self) -> CmdResult { + if matches!(self.step, SubtractStep::ConvertMeshes) { + return self.finish(true); + } if self.selected.is_empty() { return CmdResult::NeedPoint; } match self.step { SubtractStep::Bases => { self.bases = std::mem::take(&mut self.selected); + self.bases_have_mesh = self.selected_has_mesh; + self.selected_has_mesh = false; self.step = SubtractStep::Cutters; CmdResult::DeselectAndContinue } - SubtractStep::Cutters => CmdResult::SolidSubtract { - bases: std::mem::take(&mut self.bases), - cutters: std::mem::take(&mut self.selected), - }, + SubtractStep::Cutters => { + self.cutters = std::mem::take(&mut self.selected); + if self.bases_have_mesh || self.selected_has_mesh { + self.step = SubtractStep::ConvertMeshes; + CmdResult::NeedPoint + } else { + self.finish(false) + } + } + SubtractStep::ConvertMeshes => unreachable!(), } } + fn wants_text_input(&self) -> bool { + matches!(self.step, SubtractStep::ConvertMeshes) + } + + fn on_text_input(&mut self, text: &str) -> Option { + if !matches!(self.step, SubtractStep::ConvertMeshes) { + return None; + } + Some(match text.trim().to_ascii_lowercase().as_str() { + "" | "y" | "yes" => self.finish(true), + "n" | "no" => self.finish(false), + _ => CmdResult::NeedPoint, + }) + } + fn is_selection_gathering(&self) -> bool { - true + !matches!(self.step, SubtractStep::ConvertMeshes) } fn inject_selection_entities(&mut self, entities: Vec) { + self.selected_has_mesh = entities.iter().any(|entity| { + matches!( + entity.entity, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + ) + }); self.selected = entities .into_iter() .filter_map(|entity| { - matches!(entity.entity, EntityType::Solid3D(_)).then_some(entity.handle) + matches!( + entity.entity, + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Surface(_) + | EntityType::Mesh(_) + | EntityType::PolygonMesh(_) + | EntityType::PolyfaceMesh(_) + ) + .then_some(entity.handle) }) .collect(); } diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 3130cb3bc..e292f0ee6 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -222,11 +222,9 @@ pub fn has_compact_solid_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> bool { - has_specialized_primitive_properties(document, handle) - || matches!( - document.solid_history_operation(handle), - Some(SolidHistoryOperation::Brep(_)) - ) + document + .get_entity(handle) + .is_some_and(|entity| matches!(entity, acadrust::EntityType::Solid3D(_))) } pub fn reference_point(operation: &SolidHistoryOperation) -> Option { @@ -1441,7 +1439,21 @@ pub fn primitive_properties( handle: acadrust::Handle, ) -> Vec { let Some(operation) = document.solid_history_operation(handle) else { - return Vec::new(); + return vec![PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::ReadOnly("None".to_string()), + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: PropValue::ReadOnly("No".to_string()), + }, + ], + }]; }; match operation { SolidHistoryOperation::Box(value) => { @@ -1462,7 +1474,7 @@ pub fn primitive_properties( } SolidHistoryOperation::Revolve(value) => revolve_properties(document, handle, value), SolidHistoryOperation::Brep(_) => brep_properties(document, handle), - _ => Vec::new(), + _ => brep_properties(document, handle), } } From eb83e551b8cc8938f561a4ba72f9091256482f05 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:05:45 +0300 Subject: [PATCH 08/71] Make Solid History choices editable --- src/scene/model/solid_history.rs | 16 +-------- src/scene/modify.rs | 59 ++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index e292f0ee6..c52d28732 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1439,21 +1439,7 @@ pub fn primitive_properties( handle: acadrust::Handle, ) -> Vec { let Some(operation) = document.solid_history_operation(handle) else { - return vec![PropSection { - title: t!("Solid History").into_owned(), - props: vec![ - Property { - label: t!("History").into_owned(), - field: PROP_HISTORY, - value: PropValue::ReadOnly("None".to_string()), - }, - Property { - label: t!("Show History").into_owned(), - field: PROP_SHOW_HISTORY, - value: PropValue::ReadOnly("No".to_string()), - }, - ], - }]; + return brep_properties(document, handle); }; match operation { SolidHistoryOperation::Box(value) => { diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 42c172d82..a37dfc39c 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1046,16 +1046,71 @@ impl Scene { value: &str, ) -> bool { self.record_solid_history_before(handle); + let mut created = false; + if self.document.solid_history_graph(handle).is_none() { + let create = match field { + crate::scene::model::solid_history::PROP_HISTORY => { + if value.eq_ignore_ascii_case("Record") { + true + } else if value.eq_ignore_ascii_case("None") { + return false; + } else { + return false; + } + } + crate::scene::model::solid_history::PROP_SHOW_HISTORY + if self.document.header.show_solid_history.clamp(0, 2) == 1 => + { + if value.eq_ignore_ascii_case("Yes") { + true + } else if value.eq_ignore_ascii_case("No") { + return false; + } else { + return false; + } + } + _ => return false, + }; + if create { + if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_))) { + return false; + } + self.restore_solid_models(&[handle]); + let Some(body) = self.solid_models.get(&handle).cloned() else { + return false; + }; + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if !self.create_solid_history( + handle, + crate::scene::model::solid_history::brep_op(&body), + ) { + return false; + } + // A solid without a graph was displayed as History=None. + // Preserve that object state before applying the requested + // choice, independent of the drawing-wide SOLIDHIST setting. + crate::scene::model::solid_history::apply_history_choice( + &mut self.document, + handle, + crate::scene::model::solid_history::PROP_HISTORY, + "None", + ); + created = true; + } + } let applied = crate::scene::model::solid_history::apply_history_choice( &mut self.document, handle, field, value, ); - if applied { + if created || applied { self.bump_entities(&[(handle, ChangeKind::Modified)]); } - applied + created || applied } fn apply_solid_history_grip( From 0425f979f9ed93433a3f23234d4bff970358e148 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 13:50:12 +0100 Subject: [PATCH 09/71] feat(render): GPU analytical rendering for circles, arcs, and ellipses - Render planar circles, circular arcs, ellipses, and elliptical arcs analytically via GPU-instanced screen-space quads and WGSL shaders. - Implement exact arc sweep clamping with round end caps matching the CAD wire shader. - Implement robust Newton-Raphson closest-point solver for ellipses with isotropic screen-space derivative anti-aliasing. - Integrate into wire pipeline and PersistentWireArena upload paths. - Remove unused mesh_volume_centroid function in doc_api. --- src/app/doc_api.rs | 10 +- src/entities/circle.rs | 148 ++++++++++- src/entities/dimension.rs | 15 ++ src/entities/ellipse.rs | 28 +- src/scene/cache/block_cache.rs | 28 +- src/scene/convert/tessellate.rs | 4 +- src/scene/mod.rs | 86 +++++- src/scene/model/wire_model.rs | 9 + src/scene/paper.rs | 14 +- src/scene/pipeline/circle_gpu.rs | 374 ++++++++++++++++++++++++++ src/scene/pipeline/ellipse_gpu.rs | 420 ++++++++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 233 ++++++++++++++++- src/scene/pipeline/wire_arena.rs | 6 +- src/scene/view/render.rs | 24 +- src/shaders/circle.wgsl | 224 ++++++++++++++++ src/shaders/ellipse.wgsl | 275 +++++++++++++++++++ src/snap.rs | 2 + 17 files changed, 1860 insertions(+), 40 deletions(-) create mode 100644 src/scene/pipeline/circle_gpu.rs create mode 100644 src/scene/pipeline/ellipse_gpu.rs create mode 100644 src/shaders/circle.wgsl create mode 100644 src/shaders/ellipse.wgsl diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 360daa23a..d749e4a6c 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -999,14 +999,6 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { } } -/// Signed volume and centroid of a closed triangle mesh via the divergence -/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source -/// of truth for both (used by the cold-cache `volume`/`centroid` fallback). -fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - // Delegate to the crate's single source of truth (divergence theorem). - ocs_doc_api::geom::mesh_volume_centroid(mesh) -} - #[cfg(test)] mod tests { use super::*; @@ -3235,7 +3227,7 @@ mod tests { let body = crate::scene::convert::solid3d_tess::kernel_body(solid) .expect("re-lift reloaded solid failed"); let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - let vol = mesh_volume_centroid(&mesh).0; + let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0; assert!( (vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}" diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 34d579453..bc037abfb 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -10,7 +10,39 @@ use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, R use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::TangentGeom; -fn to_render(circle: &Circle) -> RenderEntity { +/// The number of segments to tessellate a circle into, based on its radius and the +/// current viewport's world units per pixel (`wpp`). +/// +/// Targets a maximum chord sagitta of ~0.25 pixels on screen so circles stay visually +/// round at any zoom level, while bounded between a minimum of 48 (preventing +/// coarse polygons when small or far away) and a maximum of 4096 (capping buffer size +/// on extreme zoom). +pub fn circle_segments(radius: f64, wpp: Option) -> usize { + const MIN_SEGMENTS: usize = 48; + const MAX_SEGMENTS: usize = 4096; + let Some(w) = wpp.filter(|&w| w.is_finite() && w > 0.0) else { + return MIN_SEGMENTS; + }; + let r = radius.abs(); + if !r.is_finite() || r <= 0.0 { + return MIN_SEGMENTS; + } + // Target chord sagitta of ~0.25 px on screen: + // sag = r * (1 - cos(theta / 2)) <= tolerance + let tolerance = (w as f64) * 0.25; + if r <= tolerance { + return MIN_SEGMENTS; + } + let cos_half = (1.0 - tolerance / r).clamp(-1.0, 1.0); + let step = 2.0 * cos_half.acos(); + if step <= 0.0 { + return MAX_SEGMENTS; + } + let n = (std::f64::consts::TAU / step).ceil() as usize; + n.clamp(MIN_SEGMENTS, MAX_SEGMENTS) +} + +pub fn to_render_with_wpp(circle: &Circle, wpp: Option) -> RenderEntity { let cx = circle.center.x; let cy = circle.center.y; let cz = circle.center.z; @@ -31,10 +63,12 @@ fn to_render(circle: &Circle) -> RenderEntity { radius: r, }; + let n = circle_segments(r, wpp); + if circle.thickness.abs() > 1e-10 { let t = circle.thickness; let (nx, ny, nz) = normal; - let n = 64usize; + let n = n.max(64); let tau = std::f64::consts::TAU; let circ_pt = |a: f64| -> (f64, f64, f64) { let (c, s) = (a.cos(), a.sin()); @@ -76,7 +110,8 @@ fn to_render(circle: &Circle) -> RenderEntity { } // Keep tessellation on the entity curve for large-coordinate precision. - let pts = crate::entities::curve::curve_points(&curve); + let max_angle = std::f64::consts::TAU / (n as f64); + let pts = curve.tessellate_angle(max_angle); RenderEntity { pick_tris: Vec::new(), @@ -88,6 +123,22 @@ fn to_render(circle: &Circle) -> RenderEntity { } } +fn to_render(circle: &Circle) -> RenderEntity { + to_render_with_wpp(circle, None) +} + +/// Zoom-aware render converter for EntityType::Circle. +pub fn relative_render( + entity: &acadrust::EntityType, + _document: &acadrust::CadDocument, + wpp: Option, +) -> Option { + let acadrust::EntityType::Circle(circle) = entity else { + return None; + }; + Some(to_render_with_wpp(circle, wpp)) +} + fn grips(circle: &Circle) -> Vec { let center = circle.center_wcs(); let (axis_x, axis_y) = circle.axes_wcs(); @@ -220,3 +271,94 @@ impl crate::entities::traits::MassPropsCalc for Circle { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_circle_segments_scales_with_zoom() { + let r = 10.0; + // Default / invalid cases return baseline floor of 48. + assert_eq!(circle_segments(r, None), 48); + assert_eq!(circle_segments(r, Some(-1.0)), 48); + assert_eq!(circle_segments(r, Some(0.0)), 48); + assert_eq!(circle_segments(r, Some(f32::NAN)), 48); + assert_eq!(circle_segments(r, Some(f32::INFINITY)), 48); + + // Zoomed far out: screen radius is tiny, capped at floor 48. + assert_eq!(circle_segments(r, Some(10.0)), 48); + assert_eq!(circle_segments(r, Some(2.0)), 48); + + // Zooming closer increases segments monotonically. + let seg_0_1 = circle_segments(r, Some(0.1)); + let seg_0_01 = circle_segments(r, Some(0.01)); + let seg_0_001 = circle_segments(r, Some(0.001)); + let seg_0_0001 = circle_segments(r, Some(0.0001)); + + assert!(seg_0_1 >= 48, "seg_0_1 was {seg_0_1}"); + assert!(seg_0_01 > seg_0_1, "seg_0_01 ({seg_0_01}) <= seg_0_1 ({seg_0_1})"); + assert!(seg_0_001 > seg_0_01, "seg_0_001 ({seg_0_001}) <= seg_0_01 ({seg_0_01})"); + assert!(seg_0_0001 > seg_0_001, "seg_0_0001 ({seg_0_0001}) <= seg_0_001 ({seg_0_001})"); + + // Extreme zoom in hits maximum cap of 4096. + assert_eq!(circle_segments(r, Some(1e-9)), 4096); + } + + #[test] + fn test_circle_segments_scales_with_radius() { + let wpp = Some(0.1); + let seg_small = circle_segments(1.0, wpp); + let seg_med = circle_segments(20.0, wpp); + let seg_large = circle_segments(100.0, wpp); + let seg_huge = circle_segments(500.0, wpp); + + assert_eq!(seg_small, 48); + assert!(seg_med > seg_small, "{seg_med} <= {seg_small}"); + assert!(seg_large > seg_med, "{seg_large} <= {seg_med}"); + assert!(seg_huge > seg_large, "{seg_huge} <= {seg_large}"); + } + + #[test] + fn test_circle_to_render_points_increase_with_zoom() { + let mut circle = Circle::default(); + circle.radius = 10.0; + + let far_render = to_render_with_wpp(&circle, Some(1.0)); + let close_render = to_render_with_wpp(&circle, Some(0.01)); + + let far_count = match far_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + let close_count = match close_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + + assert_eq!(far_count, 49); // 48 segments + 1 closing vertex + assert!(close_count > far_count, "close_count was {close_count} vs far_count {far_count}"); + } + + #[test] + fn test_thick_circle_to_render_points_increase_with_zoom() { + let mut circle = Circle::default(); + circle.radius = 10.0; + circle.thickness = 2.0; + + let far_render = to_render_with_wpp(&circle, Some(1.0)); + let close_render = to_render_with_wpp(&circle, Some(0.01)); + + let far_count = match far_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + let close_count = match close_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + + assert!(close_count > far_count, "{close_count} <= {far_count}"); + } +} + diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index a219b0de3..667990bd2 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -5408,6 +5408,21 @@ fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { *axis_x = map_vector(*axis_x); *axis_y = map_vector(*axis_y); } + TangentGeom::PlanarEllipse { + center, + major_axis, + normal, + .. + } => { + let mapped = map(center[0], center[1], center[2]); + let map_vector = |axis: [f64; 3]| { + let mapped = map(axis[0], axis[1], axis[2]); + [mapped.0, mapped.1, mapped.2] + }; + *center = [mapped.0, mapped.1, mapped.2]; + *major_axis = map_vector(*major_axis); + *normal = map_vector(*normal); + } } } wire.aabb = WireModel::UNBOUNDED_AABB; diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index 297f140b6..6530f9e38 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -15,6 +15,7 @@ use crate::entities::common::{ use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::wire_model::TangentGeom; fn to_render(ell: &Ellipse) -> RenderEntity { // ELLIPSE is one of the few WCS entities in DXF: `center` (code 10) and @@ -29,6 +30,15 @@ fn to_render(ell: &Ellipse) -> RenderEntity { .map(crate::entities::curve::snap_from) .unwrap_or_default(); + let tangent = TangentGeom::PlanarEllipse { + center: [ell.center.x, ell.center.y, ell.center.z], + major_axis: [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z], + normal: [ell.normal.x, ell.normal.y, ell.normal.z], + minor_axis_ratio: ell.minor_axis_ratio, + start_param: ell.start_parameter, + end_param: ell.end_parameter, + }; + // The points come from the entity's own kernel curve and angular policy. // // What does not change is the shape of the object. EXTRUDE, REVOLVE and @@ -39,7 +49,7 @@ fn to_render(ell: &Ellipse) -> RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(Vec::new()), snap_pts: snap.snap_pts, - tangent_geoms: vec![], + tangent_geoms: vec![tangent.clone()], key_vertices: vec![], fill_tris: vec![], }; @@ -57,7 +67,7 @@ fn to_render(ell: &Ellipse) -> RenderEntity { pick_tris: Vec::new(), object, snap_pts: snap.snap_pts, - tangent_geoms: vec![], + tangent_geoms: vec![tangent], key_vertices: vec![], fill_tris: vec![], } @@ -284,16 +294,16 @@ fn apply_axis_grip(ell: &mut Ellipse, grip_id: usize, point: glam::DVec3) { fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { match (grip_id, apply) { (0, GripApply::Translate(d)) => { - ell.center.x += d.x as f64; - ell.center.y += d.y as f64; - ell.center.z += d.z as f64; + ell.center.x += d.x; + ell.center.y += d.y; + ell.center.z += d.z; } (0, GripApply::Absolute(p)) => { - ell.center.x = p.x as f64; - ell.center.y = p.y as f64; - ell.center.z = p.z as f64; + ell.center.x = p.x; + ell.center.y = p.y; + ell.center.z = p.z; } - (1 | 2 | 3 | 4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p), + (1..=4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p), (5 | 6, GripApply::Absolute(p)) => { let Some(parameter) = parameter_at_point(ell, p) else { return; diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index aa8acca81..9416faae6 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1268,7 +1268,8 @@ fn translated_prototype_wire( } } TangentGeom::PlanarCircle { center, .. } - | TangentGeom::Arc { center, .. } => { + | TangentGeom::Arc { center, .. } + | TangentGeom::PlanarEllipse { center, .. } => { for axis in 0..3 { center[axis] += delta[axis]; } @@ -2727,6 +2728,31 @@ fn transform_tangent( radius: radius * ((sx + sy) * 0.5), }) } + TangentGeom::PlanarEllipse { + center, + major_axis, + normal, + minor_axis_ratio, + start_param, + end_param, + } => { + let c = t.apply(Vector3::new(center[0], center[1], center[2])); + let m = t.apply_rotation(Vector3::new(major_axis[0], major_axis[1], major_axis[2])); + let n = t.apply_rotation(Vector3::new(normal[0], normal[1], normal[2])); + let n_len = n.length(); + if n_len <= 1.0e-12 { + return None; + } + let n = n / n_len; + Some(TangentGeom::PlanarEllipse { + center: [c.x, c.y, c.z], + major_axis: [m.x, m.y, m.z], + normal: [n.x, n.y, n.z], + minor_axis_ratio: *minor_axis_ratio, + start_param: *start_param, + end_param: *end_param, + }) + } } } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 5d0044060..d933002e1 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -707,9 +707,11 @@ pub fn tessellate( } // ── Try the kernel path first ─────────────────────────────────────────── + // Zoom-adaptive circles scale their segment count from the current zoom level and radius. // Relative-PDSIZE points size their glyph from the current zoom so they // stay a roughly constant on-screen size; otherwise the header-driven path. - let te = crate::entities::point::relative_render(entity, document, world_per_pixel) + let te = crate::entities::circle::relative_render(entity, document, world_per_pixel) + .or_else(|| crate::entities::point::relative_render(entity, document, world_per_pixel)) .or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel)) .or_else(|| match entity { EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index fb48c0cef..1aa4c7269 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2232,6 +2232,7 @@ impl Scene { self.annotation_all_visible(), None, None, + None, ); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); @@ -5161,9 +5162,9 @@ impl Scene { pub(super) fn model_tile_wires_arc( &self, _tile_idx: usize, - _cam: &Camera, + cam: &Camera, _cam_aspect: f32, - _tile_pixel_height: f32, + tile_pixel_height: f32, ) -> Arc> { // In a BEDIT block editor the resident set is the edited block's own // (block-local) entities; otherwise model space. (#261) @@ -5174,7 +5175,12 @@ impl Scene { &self.document, &self.document.header.current_annotation_scale, ); - self.resident_wires_for(block, None, scale, None, None) + let wpp = if tile_pixel_height > 0.0 { + Some((2.0 * cam.ortho_size()) / tile_pixel_height) + } else { + self.world_per_pixel() + }; + self.resident_wires_for(block, None, scale, None, None, wpp) } /// Unified static-hold wire builder — the ONE tessellation path every @@ -5223,6 +5229,7 @@ impl Scene { annotation_scale_handle: Option, frozen_layers: Option<&HashSet>, style_viewport: Option, + wpp: Option, ) -> Arc> { // Normalize an inert anno override away so distinct viewport scales // share one resident set when annotation can't change the wires. @@ -5239,6 +5246,7 @@ impl Scene { self.paper_bg_color }; let all_visible = self.annotation_all_visible(); + let quantized_wpp = Self::quantize_wpp(wpp); let key = self.resident_wire_key( block, bg, @@ -5247,6 +5255,7 @@ impl Scene { all_visible, frozen_layers, style_viewport, + quantized_wpp, ); { let sets = self.resident_wire_sets.borrow(); @@ -5271,17 +5280,18 @@ impl Scene { all_visible, frozen_layers, style_viewport, + quantized_wpp, ) { return arc; } - // Build once: full tessellation, no cull (region = None), no zoom LOD - // (wpp = None) — for every space, exactly like the Model static-hold. + // Build once: full tessellation, no cull (region = None), zoom LOD + // bounded by quantized_wpp for every space. let t_tess = iced::time::Instant::now(); let mut wires = self.wires_for_block_culled( block, None, - None, + quantized_wpp, frozen_layers, anno_scale_override, annotation_scale_handle, @@ -5327,6 +5337,9 @@ impl Scene { // spaces or re-scaling a viewport can't accumulate dead full sets. let cur_epoch = self.geometry_epoch; sets.retain(|_, set| set.epoch == cur_epoch); + if sets.len() > 16 { + sets.clear(); + } sets.insert( key, ResidentWireSet { @@ -5348,6 +5361,7 @@ impl Scene { all_visible: bool, frozen_layers: Option<&HashSet>, style_viewport: Option, + quantized_wpp: Option, ) -> u64 { let mut key: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = @@ -5362,6 +5376,9 @@ impl Scene { mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); mix(self.viewport_style_key(style_viewport)); + mix(quantized_wpp + .map(|w| w.to_bits() as u64) + .unwrap_or(u64::MAX)); match frozen_layers { Some(frozen) => { let mut signature = 0u64; @@ -5376,6 +5393,18 @@ impl Scene { key } + /// Quantize world units per pixel (`wpp`) into half-octave bands (~1.414x zoom ratio). + /// + /// This provides discrete, stable zoom bands so that panning (where wpp is constant) + /// and small subpixel viewport drifts or mouse-wheel motions within a band do not + /// cause re-tessellation or GPU buffer re-uploads, while zooming past a band threshold + /// smoothly scales circle tessellation detail. + pub fn quantize_wpp(wpp: Option) -> Option { + let w = wpp.filter(|&w| w.is_finite() && w > 0.0)?; + let step = (w.log2() * 2.0).floor() * 0.5; + Some(2.0f32.powf(step)) + } + /// The GPU wire-arena handoff for a viewport whose content id is `gen`: /// `(prev_gen, changed handles)` when the Model set reached `gen` via an /// incremental resident patch, else `None`. Read (not consumed) so every @@ -5462,6 +5491,7 @@ impl Scene { all_visible: bool, frozen_layers: Option<&HashSet>, style_viewport: Option, + quantized_wpp: Option, ) -> Option>> { if self.viewport_style_key(style_viewport) != 0 { return None; @@ -5545,7 +5575,7 @@ impl Scene { e, Some(&blk), None, - None, + quantized_wpp, anno_scale_override.is_some(), ); memo_updates.push((*h, Arc::new(raw.clone()))); @@ -5827,7 +5857,7 @@ impl Scene { } let layout_block = self.current_layout_block_handle(); let scale = self.paper_annotation_scale_handle(); - let base = self.resident_wires_for(layout_block, None, scale, None, None); + let base = self.resident_wires_for(layout_block, None, scale, None, None, None); let mut wires = (*base).clone(); // The overall "sheet" viewport now IS the paper view itself, so its own // border rectangle must not be drawn as an entity on the sheet. @@ -9061,7 +9091,7 @@ impl Scene { static EN: std::sync::OnceLock = std::sync::OnceLock::new(); *EN.get_or_init(|| std::env::var("OCS_NO_RESIDENT_MEMO").is_err()) } - let resident = base_ok && view_aabb.is_none() && wpp.is_none() && resident_memo_enabled(); + let resident = base_ok && view_aabb.is_none() && resident_memo_enabled(); let memo_active = base_ok && (view_aabb.is_some() || resident); // Reported in one line below. `tess_ms` / `memo_misses` are assigned by // both branches; the rest exist only on the memo path and stay zero. @@ -10200,6 +10230,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", scale, None, None, + None, ); let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied()); let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| { @@ -11213,6 +11244,7 @@ mod layout_cache_tests { scale, None, None, + None, ); let generation = s.last_model_wire_gen.get(); let bounds = s.model_space_extents().unwrap(); @@ -11226,4 +11258,40 @@ mod layout_cache_tests { assert!(!source.is_empty()); } } + + #[test] + fn circle_wires_scale_with_zoom_level() { + let mut s = Scene::new(); + let mut circle = acadrust::entities::Circle::default(); + circle.radius = 100.0; + let handle = s.add_entity(EntityType::Circle(circle)); + + // Far camera: distance is large -> wpp is large + let mut cam_far = Camera::default(); + cam_far.distance = 1000.0; + + // Close camera: distance is small -> wpp is small + let mut cam_close = Camera::default(); + cam_close.distance = 1.0; + + let wires_far = s.model_tile_wires_arc(0, &cam_far, 1.0, 1000.0); + let circle_wire_far = wires_far + .iter() + .find(|w| w.name == handle.value().to_string()) + .unwrap(); + let far_pts = circle_wire_far.points.len(); + + let wires_close = s.model_tile_wires_arc(0, &cam_close, 1.0, 1000.0); + let circle_wire_close = wires_close + .iter() + .find(|w| w.name == handle.value().to_string()) + .unwrap(); + let close_pts = circle_wire_close.points.len(); + + assert!( + close_pts > far_pts, + "Close camera points ({close_pts}) should exceed far camera points ({far_pts})" + ); + assert!(close_pts > 500, "Close points ({close_pts}) should be > 500"); + } } diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index a7938bfa3..1f76e7129 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -47,6 +47,15 @@ pub enum TangentGeom { start_angle: f64, end_angle: f64, }, + /// Ellipse or elliptical arc in an arbitrary world-space plane. + PlanarEllipse { + center: [f64; 3], + major_axis: [f64; 3], + normal: [f64; 3], + minor_axis_ratio: f64, + start_param: f64, + end_param: f64, + }, } /// Viewport-relative display transform for a point marker. diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 450074122..ed6e75e38 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -819,7 +819,7 @@ impl Scene { fn model_wires_for_viewport( &self, vp_handle: Handle, - _screen_height_px: f32, + screen_height_px: f32, ) -> Arc> { use rustc_hash::FxHashSet as HSet; @@ -829,12 +829,17 @@ impl Scene { // wheel tick whenever the drawing contained one annotative object. // Explicit viewport annotation-scale changes still rebuild the resident // set; PSLTSCALE is a viewport GPU uniform. - let frozen = match self.document.get_entity(vp_handle) { + let (frozen, wpp) = match self.document.get_entity(vp_handle) { Some(EntityType::Viewport(vp)) => { let f: HSet = vp.frozen_layers.iter().cloned().collect(); - f + let w = if screen_height_px > 0.0 && vp.view_height > 0.0 { + Some((vp.view_height as f32) / screen_height_px) + } else { + None + }; + (f, w) } - _ => HSet::default(), + _ => (HSet::default(), None), }; let scale_handle = self.viewport_scale_handle(vp_handle); @@ -844,6 +849,7 @@ impl Scene { scale_handle, Some(&frozen), Some(vp_handle), + wpp, ) } diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs new file mode 100644 index 000000000..8c32369a0 --- /dev/null +++ b/src/scene/pipeline/circle_gpu.rs @@ -0,0 +1,374 @@ +//! GPU-instanced analytical circle buffer & pipeline layout. +//! +//! Renders 2D/planar circles as analytical screen/plane-aligned quads with +//! screen-space derivative anti-aliasing. Each circle is a single instance +//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom +//! and providing infinite visual smoothness. + +use iced::wgpu; + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct CircleInstance { + pub center_high: [f32; 3], + pub _pad0: f32, + pub center_low: [f32; 3], + pub radius: f32, + pub axis_x: [f32; 3], + pub start_angle: f32, + pub axis_y: [f32; 3], + pub end_angle: f32, + pub color: [f32; 4], + /// `[half_width, pattern_length, draw_depth, padding]` + pub params: [f32; 4], + pub pat0: [f32; 4], + pub pat1: [f32; 4], +} + +impl CircleInstance { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, center_high) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, center_low) as u64, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, axis_x) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, axis_y) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, color) as u64, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, params) as u64, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, pat0) as u64, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, pat1) as u64, + shader_location: 7, + format: wgpu::VertexFormat::Float32x4, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRS, + } + } +} + +pub struct CircleGpu { + pub instance_buffer: wgpu::Buffer, + pub instance_count: u32, +} + +impl CircleGpu { + pub fn from_instances( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + instances: &[CircleInstance], + ) -> Self { + let instance_buffer = super::gpu_upload::upload_buffer( + device, + queue, + label, + instances, + wgpu::BufferUsages::VERTEX, + ); + Self { + instance_buffer, + instance_count: instances.len() as u32, + } + } +} + +/// Build the base and xray circle pipelines. +pub fn create_pipelines( + device: &wgpu::Device, + frame_bgl: &wgpu::BindGroupLayout, + color_format: wgpu::TextureFormat, + sample_count: u32, + content_stencil: &wgpu::StencilState, +) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("circle.wgsl"), + source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("circle.pipeline.layout"), + bind_group_layouts: &[Some(frame_bgl)], + immediate_size: 0, + }); + let circle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("circle.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[CircleInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + let circle_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("circle_xray.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[CircleInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + (circle_pipeline, circle_xray_pipeline) +} + +/// Helper to extract an analytical circle or circular arc instance from a `WireModel`. +pub fn extract_circle_instance( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + // Only thin 2D planar circles or circular arcs have exactly 1 PlanarCircle / Arc tangent geometry + // and no 3D mesh fills or text quads. + if wire.tangent_geoms.len() != 1 + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + || wire.render_instance.is_some() + { + return None; + } + let (center, axis_x, axis_y, radius, start_angle, end_angle) = match wire.tangent_geoms[0] { + crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center, + axis_x, + axis_y, + radius, + } => (center, axis_x, axis_y, radius, 0.0f32, std::f32::consts::TAU), + crate::scene::model::wire_model::TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } => ( + center, + axis_x, + axis_y, + radius, + start_angle as f32, + end_angle as f32, + ), + _ => return None, + }; + if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() { + return None; + } + + let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]); + let hw = wire.line_weight_px * 0.5; + let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; + let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + + Some(CircleInstance { + center_high: ch, + _pad0: 0.0, + center_low: cl, + radius: radius as f32, + axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32], + start_angle, + axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32], + end_angle, + color: wire.color, + params: [hw, wire.pattern_length, draw_depth, 0.0], + pat0, + pat1, + }) +} + +fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { + let hx = x as f32; + let lx = (x - hx as f64) as f32; + let hy = y as f32; + let ly = (y - hy as f64) as f32; + let hz = z as f32; + let lz = (z - hz as f64) as f32; + ([hx, hy, hz], [lx, ly, lz]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn circle_instance_size_matches_stride() { + assert_eq!(std::mem::size_of::(), 128); + } + + #[test] + fn circle_shader_validates_with_naga() { + let source = include_str!("../../shaders/circle.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly"); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .expect("circle.wgsl validates cleanly with naga"); + } + + #[test] + fn extract_circle_instance_planar_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center: [100.0, 200.0, 300.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 50.0, + }); + wire.line_weight_px = 2.0; + wire.color = [1.0, 0.0, 0.0, 1.0]; + + let inst = extract_circle_instance(&wire, 1.5).expect("should extract planar circle"); + assert_eq!(inst.radius, 50.0); + assert_eq!(inst.start_angle, 0.0); + assert_eq!(inst.end_angle, std::f32::consts::TAU); + assert_eq!(inst.center_high, [100.0, 200.0, 300.0]); + assert_eq!(inst.color, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(inst.params[0], 1.0); // half lineweight + assert_eq!(inst.params[2], 1.5); // draw depth + } + + #[test] + fn extract_circle_instance_arc_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [10.0, 20.0, 30.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 25.0, + start_angle: 0.5, + end_angle: 2.5, + }); + wire.line_weight_px = 3.0; + wire.color = [0.0, 1.0, 0.0, 1.0]; + + let inst = extract_circle_instance(&wire, 0.5).expect("should extract circular arc"); + assert_eq!(inst.radius, 25.0); + assert_eq!(inst.start_angle, 0.5); + assert_eq!(inst.end_angle, 2.5); + assert_eq!(inst.center_high, [10.0, 20.0, 30.0]); + assert_eq!(inst.color, [0.0, 1.0, 0.0, 1.0]); + assert_eq!(inst.params[0], 1.5); + } + + #[test] + fn extract_circle_instance_rejects_non_planar_or_complex() { + let mut wire = crate::scene::WireModel::default(); + 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, + }); + // 1. With fill_tris (e.g. 3D solid or hatch) + wire.fill_tris = vec![[0.0; 3]]; + assert!(extract_circle_instance(&wire, 0.0).is_none()); + + // 2. Clear fill_tris, add render_instance (block instance) + wire.fill_tris.clear(); + wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance { + source_id: 1, + translation: [0.0; 3], + }); + assert!(extract_circle_instance(&wire, 0.0).is_none()); + + // 3. Invalid radius + wire.render_instance = None; + wire.tangent_geoms[0] = 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: -5.0, + }; + assert!(extract_circle_instance(&wire, 0.0).is_none()); + } +} diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs new file mode 100644 index 000000000..1c6b0765c --- /dev/null +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -0,0 +1,420 @@ +//! GPU-instanced analytical ellipse buffer & pipeline layout. +//! +//! Renders 2D/planar ellipses and elliptical arcs as analytical screen/plane-aligned quads +//! with sub-pixel screen-space derivative anti-aliasing. Each ellipse is a single instance +//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom and providing +//! mathematically exact curvature at any magnification. + +use iced::wgpu; + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct EllipseInstance { + pub center_high: [f32; 3], + pub _pad0: f32, + pub center_low: [f32; 3], + pub minor_axis_ratio: f32, + pub major_axis: [f32; 3], + pub start_param: f32, + pub normal: [f32; 3], + pub end_param: f32, + pub color: [f32; 4], + /// `[half_width, pattern_length, draw_depth, padding]` + pub params: [f32; 4], + pub pat0: [f32; 4], + pub pat1: [f32; 4], +} + +impl EllipseInstance { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, center_high) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, center_low) as u64, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, major_axis) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, normal) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, color) as u64, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, params) as u64, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, pat0) as u64, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, pat1) as u64, + shader_location: 7, + format: wgpu::VertexFormat::Float32x4, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRS, + } + } +} + +pub struct EllipseGpu { + pub instance_buffer: wgpu::Buffer, + pub instance_count: u32, +} + +impl EllipseGpu { + pub fn from_instances( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + instances: &[EllipseInstance], + ) -> Self { + let instance_buffer = super::gpu_upload::upload_buffer( + device, + queue, + label, + instances, + wgpu::BufferUsages::VERTEX, + ); + Self { + instance_buffer, + instance_count: instances.len() as u32, + } + } +} + +/// Build the base and xray ellipse pipelines. +pub fn create_pipelines( + device: &wgpu::Device, + frame_bgl: &wgpu::BindGroupLayout, + color_format: wgpu::TextureFormat, + sample_count: u32, + content_stencil: &wgpu::StencilState, +) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ellipse.wgsl"), + source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ellipse.pipeline.layout"), + bind_group_layouts: &[Some(frame_bgl)], + immediate_size: 0, + }); + let ellipse_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ellipse.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[EllipseInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + let ellipse_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ellipse_xray.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[EllipseInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + (ellipse_pipeline, ellipse_xray_pipeline) +} + +/// Helper to extract an analytical ellipse instance from a `WireModel`. +pub fn extract_ellipse_instance( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + if wire.tangent_geoms.len() != 1 + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + || wire.render_instance.is_some() + { + return None; + } + let crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center, + major_axis, + normal, + minor_axis_ratio, + start_param, + end_param, + } = wire.tangent_geoms[0] else { + return None; + }; + + 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() + || 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() + { + return None; + } + + let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]); + let hw = wire.line_weight_px * 0.5; + let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; + let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + + Some(EllipseInstance { + center_high: ch, + _pad0: 0.0, + center_low: cl, + minor_axis_ratio: minor_axis_ratio.min(1.0) as f32, + major_axis: [major_axis[0] as f32, major_axis[1] as f32, major_axis[2] as f32], + start_param: start_param as f32, + normal: [normal[0] as f32, normal[1] as f32, normal[2] as f32], + end_param: end_param as f32, + color: wire.color, + params: [hw, wire.pattern_length, draw_depth, 0.0], + pat0, + pat1, + }) +} + +fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { + let hx = x as f32; + let lx = (x - hx as f64) as f32; + let hy = y as f32; + let ly = (y - hy as f64) as f32; + let hz = z as f32; + let lz = (z - hz as f64) as f32; + ([hx, hy, hz], [lx, ly, lz]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ellipse_instance_size_matches_stride() { + assert_eq!(std::mem::size_of::(), 128); + } + + #[test] + fn ellipse_shader_validates_with_naga() { + let source = include_str!("../../shaders/ellipse.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly"); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .expect("ellipse.wgsl validates cleanly with naga"); + } + + #[test] + fn extract_ellipse_instance_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center: [100.0, 200.0, 300.0], + major_axis: [50.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, + }); + wire.line_weight_px = 2.0; + wire.color = [1.0, 0.5, 0.0, 1.0]; + + let inst = extract_ellipse_instance(&wire, 1.0).expect("should extract ellipse"); + assert_eq!(inst.center_high, [100.0, 200.0, 300.0]); + assert_eq!(inst.major_axis, [50.0, 0.0, 0.0]); + assert_eq!(inst.minor_axis_ratio, 0.5); + assert_eq!(inst.start_param, 0.0); + assert_eq!(inst.end_param, std::f32::consts::TAU); + assert_eq!(inst.color, [1.0, 0.5, 0.0, 1.0]); + assert_eq!(inst.params[0], 1.0); + assert_eq!(inst.params[2], 1.0); + } + + #[test] + fn extract_elliptical_arc_instance_success() { + let mut wire = crate::scene::WireModel::default(); + 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.8, + start_param: 0.5, + end_param: 2.5, + }); + wire.line_weight_px = 1.0; + wire.color = [0.0, 1.0, 0.0, 1.0]; + + let inst = extract_ellipse_instance(&wire, 0.5).expect("should extract elliptical arc"); + assert_eq!(inst.minor_axis_ratio, 0.8); + assert_eq!(inst.start_param, 0.5); + assert_eq!(inst.end_param, 2.5); + assert_eq!(inst.params[0], 0.5); // half-width + assert_eq!(inst.params[2], 0.5); // draw-depth + } + + fn rust_closest_point_ellipse(p_in: [f32; 2], ab_in: [f32; 2]) -> [f32; 2] { + let px = p_in[0].abs(); + let py = p_in[1].abs(); + let a = ab_in[0]; + let b = ab_in[1]; + if (a - b).abs() < 1e-6 { + let len = (px * px + py * py).sqrt(); + let q = if len > 1e-6 { + [px / len * a, py / len * a] + } else { + [a, 0.0] + }; + return [ + if p_in[0] >= 0.0 { q[0] } else { -q[0] }, + if p_in[1] >= 0.0 { q[1] } else { -q[1] }, + ]; + } + + // Initial estimate from eccentric anomaly of radial direction + let mut t = (py * a).atan2(px * b); + let k = a * a - b * b; + + // 3 iterations of Newton-Raphson + for _ in 0..3 { + let (st, ct) = t.sin_cos(); + let f = k * st * ct - px * a * st + py * b * ct; + let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st; + if f_prime.abs() > 1e-7 { + t -= f / f_prime; + } + } + t = t.clamp(0.0, std::f32::consts::FRAC_PI_2); + let q = [a * t.cos(), b * t.sin()]; + [ + if p_in[0] >= 0.0 { q[0] } else { -q[0] }, + if p_in[1] >= 0.0 { q[1] } else { -q[1] }, + ] + } + + #[test] + fn closest_point_ellipse_accuracy() { + let a = 100.0f32; + let b = 40.0f32; + // Test points directly on the ellipse: closest point should be itself! + for i in 0..36 { + let angle = (i as f32) * std::f32::consts::TAU / 36.0; + let px = a * angle.cos(); + let py = b * angle.sin(); + let q = rust_closest_point_ellipse([px, py], [a, b]); + let dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt(); + assert!( + dist < 0.2, + "Point on ellipse at angle {angle} failed: px={px}, py={py}, q={:?}, dist={dist}", + q + ); + // Verify q is on the ellipse (x/a)^2 + (y/b)^2 == 1 + let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2); + assert!((eq - 1.0).abs() < 0.01, "eq={eq} for angle {angle}"); + } + + // Test points offset from ellipse (inside and outside) + for i in 0..36 { + let angle = (i as f32) * std::f32::consts::TAU / 36.0; + for offset in [-5.0f32, -1.0, 1.0, 5.0] { + let norm = [angle.cos() / a, angle.sin() / b]; + let norm_len = (norm[0] * norm[0] + norm[1] * norm[1]).sqrt(); + let un = [norm[0] / norm_len, norm[1] / norm_len]; + let px = a * angle.cos() + offset * un[0]; + let py = b * angle.sin() + offset * un[1]; + let q = rust_closest_point_ellipse([px, py], [a, b]); + let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2); + assert!( + (eq - 1.0).abs() < 0.05, + "Offset point at angle {angle}, offset {offset}: q={:?}, eq={eq}", + q + ); + let measured_dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt(); + assert!( + (measured_dist - offset.abs()).abs() < 0.2, + "Distance error at angle {angle}, offset {offset}: measured={measured_dist}" + ); + } + } + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 908fece79..f6896df0b 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod gpu_tests; mod device_capabilities; +pub mod circle_gpu; +pub mod ellipse_gpu; pub mod face3d_gpu; pub mod gpu_budget; pub mod gpu_upload; @@ -20,6 +22,8 @@ pub mod wire_gpu; use iced::wgpu; use iced::{Rectangle, Size}; +pub use circle_gpu::{CircleGpu, CircleInstance}; +pub use ellipse_gpu::{EllipseGpu, EllipseInstance}; pub use face3d_gpu::Face3DGpu; pub use wipeout_gpu::WipeoutGpu; pub use image_gpu::ImageGpu; @@ -79,6 +83,8 @@ pub struct Pipeline { shadow_pipeline: wgpu::RenderPipeline, shadow_plain_pipeline: wgpu::RenderPipeline, wire_pipeline: wgpu::RenderPipeline, + circle_pipeline: wgpu::RenderPipeline, + ellipse_pipeline: wgpu::RenderPipeline, block_wire_pipeline: wgpu::RenderPipeline, /// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP). clip_mask_pipeline: wgpu::RenderPipeline, @@ -89,6 +95,8 @@ pub struct Pipeline { /// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false. /// Used to draw ghost copies of selected wires through occluding geometry. wire_xray_pipeline: wgpu::RenderPipeline, + circle_xray_pipeline: wgpu::RenderPipeline, + ellipse_xray_pipeline: wgpu::RenderPipeline, block_wire_xray_pipeline: wgpu::RenderPipeline, /// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire / /// xray pipelines). `Some` on any storage-capable device; `None` in packed @@ -225,6 +233,8 @@ pub struct Pipeline { /// replace this thin draw-range list on camera changes without touching the /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, + pub(crate) gpu_circles: std::sync::Arc>, + pub(crate) gpu_ellipses: std::sync::Arc>, pub(crate) gpu_block_wires: std::sync::Arc>, /// Persistent per-entity wire instance arena (capability-selected format). /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an @@ -256,6 +266,8 @@ pub struct Pipeline { clip_boundary: Option<(wgpu::Buffer, u32)>, /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. gpu_selected_wires: Vec, + gpu_selected_circles: Vec, + gpu_selected_ellipses: Vec, gpu_selected_block_wires: Vec, /// Command-preview / interim / grip-drag overlay wires. Re-uploaded every /// frame they are present (small), drawn on top of the base wire pass — so @@ -2083,6 +2095,22 @@ impl Pipeline { &content_stencil, ); + let (circle_pipeline, circle_xray_pipeline) = circle_gpu::create_pipelines( + device, + &frame_bgl, + format, + MSAA_SAMPLES, + &content_stencil, + ); + + let (ellipse_pipeline, ellipse_xray_pipeline) = ellipse_gpu::create_pipelines( + device, + &frame_bgl, + format, + MSAA_SAMPLES, + &content_stencil, + ); + let viewcube = ViewCubePipeline::new(device, queue, format); let init_size = Size::new(1, 1); @@ -2223,6 +2251,10 @@ impl Pipeline { wire_black_pipeline, block_wire_black_pipeline, wire_xray_pipeline, + circle_pipeline, + circle_xray_pipeline, + ellipse_pipeline, + ellipse_xray_pipeline, block_wire_xray_pipeline, wire_const_bgl, block_wire_const_bgl, @@ -2295,6 +2327,8 @@ impl Pipeline { blit_uniform_buffer, surface_format: format, gpu_wires: std::sync::Arc::new(vec![]), + gpu_circles: std::sync::Arc::new(vec![]), + gpu_ellipses: std::sync::Arc::new(vec![]), gpu_block_wires: std::sync::Arc::new(vec![]), wire_arena: None, wire_arena_mesh: None, @@ -2308,6 +2342,8 @@ impl Pipeline { silhouette_key: (usize::MAX, u64::MAX, [u32::MAX; 3], false), clip_boundary: None, gpu_selected_wires: vec![], + gpu_selected_circles: vec![], + gpu_selected_ellipses: vec![], gpu_selected_block_wires: vec![], gpu_preview_wires: vec![], gpu_wipeouts: vec![], @@ -2371,6 +2407,66 @@ impl Pipeline { ) } + /// Upload GPU-instanced analytical circles. + pub fn upload_circles( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[WireModel], + depth_map: &rustc_hash::FxHashMap, + ) -> Vec { + let mut instances: Vec = Vec::new(); + for wire in wires { + if !wire.display_visible { + continue; + } + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(inst) = circle_gpu::extract_circle_instance(wire, depth) { + instances.push(inst); + } + } + if instances.is_empty() { + vec![] + } else { + vec![CircleGpu::from_instances( + device, + queue, + "viewer.circles", + &instances, + )] + } + } + + /// Upload GPU-instanced analytical ellipses. + pub fn upload_ellipses( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[WireModel], + depth_map: &rustc_hash::FxHashMap, + ) -> Vec { + let mut instances: Vec = Vec::new(); + for wire in wires { + if !wire.display_visible { + continue; + } + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) { + instances.push(inst); + } + } + if instances.is_empty() { + vec![] + } else { + vec![EllipseGpu::from_instances( + device, + queue, + "viewer.ellipses", + &instances, + )] + } + } + /// Build resident batches and a handle index shared across viewport slots. pub fn build_wire_buffers( &mut self, @@ -2383,6 +2479,8 @@ impl Pipeline { std::sync::Arc>, std::sync::Arc>, std::sync::Arc>>, + std::sync::Arc>, + std::sync::Arc>, ) { // Batch the wire pass: instead of one GPU buffer + one draw call per // WireModel (tens of thousands on a large drawing), merge maximal runs @@ -2407,11 +2505,19 @@ impl Pipeline { i += 1; continue; } + if circle_gpu::extract_circle_instance(&wires[i], 0.0).is_some() + || ellipse_gpu::extract_ellipse_instance(&wires[i], 0.0).is_some() + { + i += 1; + continue; + } let mesh_edge = is_mesh_edge(&wires[i]); let mut j = i + 1; while j < wires.len() && wires[j].display_visible && wires[j].render_instance.is_none() + && circle_gpu::extract_circle_instance(&wires[j], 0.0).is_none() + && ellipse_gpu::extract_ellipse_instance(&wires[j], 0.0).is_none() && is_mesh_edge(&wires[j]) == mesh_edge { j += 1; @@ -2462,10 +2568,14 @@ impl Pipeline { wires.len(), ); } + let circle_batches = self.upload_circles(device, queue, wires, depth_map); + let ellipse_batches = self.upload_ellipses(device, queue, wires, depth_map); ( std::sync::Arc::new(batches), std::sync::Arc::new(block_batches), std::sync::Arc::new(index), + std::sync::Arc::new(circle_batches), + std::sync::Arc::new(ellipse_batches), ) } @@ -2492,6 +2602,8 @@ impl Pipeline { if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { self.gpu_selected_wires = vec![]; self.gpu_selected_block_wires = vec![]; + self.gpu_selected_circles = vec![]; + self.gpu_selected_ellipses = vec![]; return; } // Gather borrowed highlighted wires via the prebuilt index — @@ -2527,15 +2639,79 @@ impl Pipeline { hover_wires.push(wire); } } + 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(mut inst) = circle_gpu::extract_circle_instance(wire, depth) { + if let Some(tint) = selected_tint { + inst.color = tint; + } + selected_circles.push(inst); + } + } + for &wire in &hover_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(mut inst) = circle_gpu::extract_circle_instance(wire, depth) { + inst.color = WireModel::HOVER; + selected_circles.push(inst); + } + } + self.gpu_selected_circles = if selected_circles.is_empty() { + vec![] + } else { + vec![CircleGpu::from_instances( + device, + queue, + "viewer.selected_circles", + &selected_circles, + )] + }; + + 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(mut inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) { + if let Some(tint) = selected_tint { + inst.color = tint; + } + selected_ellipses.push(inst); + } + } + for &wire in &hover_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(mut inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) { + inst.color = WireModel::HOVER; + selected_ellipses.push(inst); + } + } + self.gpu_selected_ellipses = if selected_ellipses.is_empty() { + vec![] + } else { + vec![EllipseGpu::from_instances( + device, + queue, + "viewer.selected_ellipses", + &selected_ellipses, + )] + }; + let selected_regular: Vec<&WireModel> = selected_wires .iter() .copied() - .filter(|wire| wire.render_instance.is_none()) + .filter(|wire| { + wire.render_instance.is_none() + && circle_gpu::extract_circle_instance(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none() + }) .collect(); let hover_regular: Vec<&WireModel> = hover_wires .iter() .copied() - .filter(|wire| wire.render_instance.is_none()) + .filter(|wire| { + wire.render_instance.is_none() + && circle_gpu::extract_circle_instance(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none() + }) .collect(); let selected_blocks: Vec<&WireModel> = selected_wires .iter() @@ -4373,6 +4549,26 @@ impl Pipeline { } bind_and_draw_block_wire(&mut pass, wire); } + // Analytical GPU circles (instanced screen-space quads) + if self.gpu_circles.iter().any(|cg| cg.instance_count > 0) { + pass.set_pipeline(&self.circle_pipeline); + for cg in self.gpu_circles.iter() { + if cg.instance_count > 0 { + pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); + pass.draw(0..6, 0..cg.instance_count); + } + } + } + // Analytical GPU ellipses (instanced screen-space quads) + if self.gpu_ellipses.iter().any(|eg| eg.instance_count > 0) { + pass.set_pipeline(&self.ellipse_pipeline); + for eg in self.gpu_ellipses.iter() { + if eg.instance_count > 0 { + pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); + pass.draw(0..6, 0..eg.instance_count); + } + } + } // Live overlay wires (command preview / interim / grip drag) always // on top: the xray pipeline (depth_compare=Always, no depth write) // keeps them visible through any occluding geometry — a 3D solid, or @@ -4563,6 +4759,32 @@ impl Pipeline { bind_and_draw_block_wire(&mut pass, wire); } } + if !self.gpu_selected_circles.is_empty() { + pass.set_pipeline(if hidden_line { + &self.circle_pipeline + } else { + &self.circle_xray_pipeline + }); + for cg in &self.gpu_selected_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_selected_ellipses.is_empty() { + pass.set_pipeline(if hidden_line { + &self.ellipse_pipeline + } else { + &self.ellipse_xray_pipeline + }); + for eg in &self.gpu_selected_ellipses { + if eg.instance_count > 0 { + pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); + pass.draw(0..6, 0..eg.instance_count); + } + } + } if let Some(atlas) = &self.text_atlas_gpu { if !self.text_highlight_gpu.is_empty() { pass.set_pipeline(&self.text_highlight_pipeline); @@ -4711,6 +4933,8 @@ impl Pipeline { self.gpu_wires = std::sync::Arc::new(Vec::new()); self.gpu_block_wires = std::sync::Arc::new(Vec::new()); + self.gpu_circles = std::sync::Arc::new(Vec::new()); + self.gpu_ellipses = std::sync::Arc::new(Vec::new()); self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default()); self.wire_arena = None; self.wire_arena_mesh = None; @@ -4721,6 +4945,8 @@ impl Pipeline { self.gpu_selected_wires.clear(); self.gpu_selected_block_wires.clear(); + self.gpu_selected_circles.clear(); + self.gpu_selected_ellipses.clear(); self.gpu_preview_wires.clear(); self.hatch_gpu.clear(); self.gpu_wipeouts.clear(); @@ -4990,6 +5216,7 @@ impl MultiPipeline { self.wire_buffer_cache.retain(|_, entry| { std::sync::Arc::strong_count(&entry.0) > 1 || std::sync::Arc::strong_count(&entry.1) > 1 + || std::sync::Arc::strong_count(&entry.3) > 1 }); self.block_geometry.retain(|_, chunks| { chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) @@ -5140,6 +5367,8 @@ pub struct MultiPipeline { std::sync::Arc>, std::sync::Arc>, std::sync::Arc>>, + std::sync::Arc>, + std::sync::Arc>, ), >, } diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 4fe85fab0..8af96ba2f 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -126,7 +126,11 @@ pub fn split_wires(wires: &[WireModel]) -> (Vec<&WireModel>, Vec<&WireModel>) { wires .iter() .filter(|wire| { - wire.display_visible && !wire.points.is_empty() && wire.render_instance.is_none() + wire.display_visible + && !wire.points.is_empty() + && wire.render_instance.is_none() + && super::circle_gpu::extract_circle_instance(wire, 0.0).is_none() + && super::ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none() }) .partition(|wire| !wire.fill_is_3d) } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 72fd2b92d..0c734c86f 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -853,6 +853,22 @@ impl shader::Primitive for Primitive { &mut pipeline.block_geometry, ), ); + inner.gpu_circles = std::sync::Arc::new( + inner.upload_circles( + device, + queue, + &vp_wires, + &draw_depths, + ), + ); + inner.gpu_ellipses = std::sync::Arc::new( + inner.upload_ellipses( + device, + queue, + &vp_wires, + &draw_depths, + ), + ); if _patched { wire_arena::patch_handle_index( &mut inner.wire_handle_index, @@ -911,10 +927,14 @@ impl shader::Primitive for Primitive { // Other panes retain their own references to shared geometry. inner.gpu_wires = std::sync::Arc::new(Vec::new()); inner.gpu_block_wires = std::sync::Arc::new(Vec::new()); + inner.gpu_circles = std::sync::Arc::new(Vec::new()); + inner.gpu_ellipses = std::sync::Arc::new(Vec::new()); let held_before = pipeline.wire_buffer_cache.len(); - pipeline.wire_buffer_cache.retain(|_, (w, b, _)| { + pipeline.wire_buffer_cache.retain(|_, (w, b, _, c, e)| { std::sync::Arc::strong_count(w) > 1 || std::sync::Arc::strong_count(b) > 1 + || std::sync::Arc::strong_count(c) > 1 + || std::sync::Arc::strong_count(e) > 1 }); if _perf { crate::perf_record!( @@ -959,6 +979,8 @@ impl shader::Primitive for Primitive { inner.gpu_wires = built.0; inner.gpu_block_wires = built.1; inner.wire_handle_index = built.2; + inner.gpu_circles = built.3; + inner.gpu_ellipses = built.4; } // end !arena_served inner.cached_wire_id = vp.wire_content_id; if _perf { diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl new file mode 100644 index 000000000..3e88c88aa --- /dev/null +++ b/src/shaders/circle.wgsl @@ -0,0 +1,224 @@ +// GPU-instanced analytical circle shader — renders 2D/planar circles as screen/plane-aligned quads. +// Topology: TriangleList, 6 vertices drawn per INSTANCE. +// +// One instance = one complete circle. The six vertex IDs map to the corners of a +// two-triangle quad covering the circle's bounding box (+ lineweight margin). +// +// The fragment shader analytically computes the exact distance to the circle perimeter: +// d_world = abs(length(local_xy) - radius) +// and uses screen-space derivatives (fwidth) to achieve perfect sub-pixel anti-aliasing +// at any zoom level, without any polygon faceting or CPU re-tessellation. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + linetype_scale: f32, + lineweight_scale: f32, + // Relative-to-eye (double-single) + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +const DRAW_ORDER_BIAS: f32 = 0.001; +const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; +const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +const TAU: f32 = 6.283185307179586; + +struct InstanceIn { + @location(0) center_high: vec4, // xyz = center_high, w = unused + @location(1) center_low: vec4, // xyz = center_low, w = radius + @location(2) axis_x: vec4, // xyz = axis_x, w = start_angle + @location(3) axis_y: vec4, // xyz = axis_y, w = end_angle + @location(4) color: vec4, // rgba + @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, unused + @location(6) pat0: vec4, + @location(7) pat1: vec4, +} + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) local_pos: vec2, + @location(1) radius: f32, + @location(2) hw_px: f32, + @location(3) color: vec4, + @location(4) pattern_length: f32, + @location(5) pat0: vec4, + @location(6) pat1: vec4, + @location(7) @interpolate(flat) min_elem: f32, + @location(8) @interpolate(flat) start_angle: f32, + @location(9) @interpolate(flat) end_angle: f32, +} + +fn resolve_hw(px_hw: f32) -> f32 { + var display_hw = max(px_hw * u.lineweight_scale, 0.5); + if u.lineweight_scale < 0.0 { + let scale = -u.lineweight_scale; + let base_hw = select( + min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), + 0.5, + px_hw <= 0.5, + ); + display_hw = max(base_hw * scale, 0.5); + } + return select(0.5, display_hw, u.lwdisplay_enable > 0.5); +} + +@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut { + // Two-triangle unit quad corner table: + // vid 0,1,2 = (-1,-1) ( 1,-1) ( 1, 1) + // vid 3,4,5 = (-1,-1) ( 1, 1) (-1, 1) + let u_arr = array(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0); + let v_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let u_val = u_arr[vid]; + let v_val = v_arr[vid]; + + let center_high = in.center_high.xyz; + let center_low = in.center_low.xyz; + let radius = in.center_low.w; + let axis_x = in.axis_x.xyz; + let axis_y = in.axis_y.xyz; + let color = in.color; + let hw_px = resolve_hw(in.params.x); + let pattern_length = in.params.y; + let draw_depth = in.params.z; + + // Expand bounding quad so anti-aliasing margin and lineweight never clip + let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6); + let ext = radius + margin_world; + + let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low); + let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext; + + var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); + clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + + var out: VertexOut; + out.clip_pos = clip_pos; + out.local_pos = vec2(u_val * ext, v_val * ext); + out.radius = radius; + out.hw_px = hw_px; + out.color = color; + out.pattern_length = pattern_length; + out.pat0 = in.pat0; + out.pat1 = in.pat1; + + // Compute smallest non-zero pattern element for LOD short-circuiting + let elems = array(in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w); + var min_elem = 1e9; + for (var i = 0u; i < 8u; i++) { + let e = abs(elems[i]); + if e > 0.0 && e < min_elem { + min_elem = e; + } + } + out.min_elem = select(0.0, min_elem, min_elem < 1e8); + + out.start_angle = in.axis_x.w; + out.end_angle = in.axis_y.w; + + return out; +} + +fn mod_tau(val: f32) -> f32 { + let m = val % TAU; + return select(m, m + TAU, m < 0.0); +} + +fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { + let elems = array(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elems[i] != 0.0 { count = i + 1u; } + } + if count == 0u { + return true; + } + + let d = ((dist % pat_len) + pat_len) % pat_len; + var pos = 0.0f; + let dot_half = u.world_per_pixel * 0.75; + for (var i = 0u; i < count; i++) { + let elem = elems[i]; + if elem == 0.0 { + let dd = abs(d - pos); + if min(dd, pat_len - dd) <= dot_half { return true; } + } else if elem > 0.0 { + if d >= pos && d < pos + elem { return true; } + pos += elem; + } else { + pos += -elem; + } + } + return false; +} + +@fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + let r = length(in.local_pos); + + // Screen-space derivative anti-aliasing: + // Use the gradient of the monotonic radial distance `r` instead of `abs(r - radius)`. + // Taking derivatives across the cusp of abs() cancels out at scanline crossings, + // which previously collapsed fw to near-zero and punched periodic 1-2px holes (false dashes). + let grad = vec2(dpdx(r), dpdy(r)); + let grad_len = length(grad); + let fw = max(select(u.world_per_pixel, grad_len, grad_len > 1e-6), 1e-6); + + let sa = in.start_angle; + let ea = in.end_angle; + var sweep = mod_tau(ea - sa); + if sweep <= 1e-5 && abs(ea - sa) > 1e-5 { + sweep = TAU; + } + + let angle = atan2(in.local_pos.y, in.local_pos.x); + let theta = select(angle, angle + TAU, angle < 0.0); + let d_theta = mod_tau(theta - sa); + + var d_world: f32; + var arc_dist: f32; + + if sweep >= TAU - 1e-5 || d_theta <= sweep { + // Inside arc sweep or full circle + d_world = abs(r - in.radius); + arc_dist = in.radius * d_theta; + } else { + // Outside sweep: measure distance to start and end endpoints for round end caps + let p_start = in.radius * vec2(cos(sa), sin(sa)); + let p_end = in.radius * vec2(cos(ea), sin(ea)); + let d_start = length(in.local_pos - p_start); + let d_end = length(in.local_pos - p_end); + if d_start < d_end { + d_world = d_start; + arc_dist = 0.0; + } else { + d_world = d_end; + arc_dist = in.radius * sweep; + } + } + + let d_px = d_world / fw; + let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0); + if alpha_cov <= 0.0 { + discard; + } + + // Linetype dash pattern test + if in.pattern_length > 0.0 { + let pat_len = in.pattern_length * u.linetype_scale; + if in.min_elem * u.linetype_scale >= u.world_per_pixel && pat_len > 0.0 { + if !in_dash(arc_dist, pat_len, in.pat0, in.pat1) { + discard; + } + } + } + + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5) * alpha_cov; + return vec4(in.color.rgb, alpha); +} diff --git a/src/shaders/ellipse.wgsl b/src/shaders/ellipse.wgsl new file mode 100644 index 000000000..b6c35ba62 --- /dev/null +++ b/src/shaders/ellipse.wgsl @@ -0,0 +1,275 @@ +// GPU-instanced analytical ellipse shader — renders 2D/planar ellipses and elliptical arcs as quads. +// Topology: TriangleList, 6 vertices drawn per INSTANCE. +// +// Evaluates Inigo Quilez's exact 2D Ellipse Signed Distance Function (quartic closest-point solver) +// with sub-pixel anti-aliasing and parametric arc endpoint clamping for round end caps. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + linetype_scale: f32, + lineweight_scale: f32, + // Relative-to-eye (double-single) + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +const DRAW_ORDER_BIAS: f32 = 0.001; +const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; +const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +const TAU: f32 = 6.283185307179586; + +struct InstanceIn { + @location(0) center_high: vec4, // xyz = center_high, w = unused + @location(1) center_low: vec4, // xyz = center_low, w = minor_axis_ratio + @location(2) major_axis: vec4, // xyz = major_axis (semi-major vector), w = start_param + @location(3) normal: vec4, // xyz = normal, w = end_param + @location(4) color: vec4, // rgba + @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, unused + @location(6) pat0: vec4, + @location(7) pat1: vec4, +} + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) local_pos: vec2, + @location(1) semi_axes: vec2, + @location(2) hw_px: f32, + @location(3) color: vec4, + @location(4) pattern_length: f32, + @location(5) pat0: vec4, + @location(6) pat1: vec4, + @location(7) @interpolate(flat) min_elem: f32, + @location(8) @interpolate(flat) start_param: f32, + @location(9) @interpolate(flat) end_param: f32, +} + +fn resolve_hw(px_hw: f32) -> f32 { + var display_hw = max(px_hw * u.lineweight_scale, 0.5); + if u.lineweight_scale < 0.0 { + let scale = -u.lineweight_scale; + let base_hw = select( + min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), + 0.5, + px_hw <= 0.5, + ); + display_hw = max(base_hw * scale, 0.5); + } + return select(0.5, display_hw, u.lwdisplay_enable > 0.5); +} + +@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut { + let u_arr = array(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0); + let v_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let u_val = u_arr[vid]; + let v_val = v_arr[vid]; + + let center_high = in.center_high.xyz; + let center_low = in.center_low.xyz; + let major_vec = in.major_axis.xyz; + let norm_vec = in.normal.xyz; + + let a = length(major_vec); + let u_axis = select(vec3(1.0, 0.0, 0.0), major_vec / a, a > 1e-6); + let n_len = length(norm_vec); + let n_axis = select(vec3(0.0, 0.0, 1.0), norm_vec / n_len, n_len > 1e-6); + let v_axis = normalize(cross(n_axis, u_axis)); + + let ratio = clamp(in.center_low.w, 1e-6, 1.0); + let b = a * ratio; + + let color = in.color; + let hw_px = resolve_hw(in.params.x); + let pattern_length = in.params.y; + let draw_depth = in.params.z; + + let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6); + let ext_u = a + margin_world; + let ext_v = b + margin_world; + + let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low); + let world_pos_rel = center_rel + (u_val * ext_u) * u_axis + (v_val * ext_v) * v_axis; + + var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); + clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + + var out: VertexOut; + out.clip_pos = clip_pos; + out.local_pos = vec2(u_val * ext_u, v_val * ext_v); + out.semi_axes = vec2(a, b); + out.hw_px = hw_px; + out.color = color; + out.pattern_length = pattern_length; + out.pat0 = in.pat0; + out.pat1 = in.pat1; + out.start_param = in.major_axis.w; + out.end_param = in.normal.w; + + let elems = array(in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w); + var min_elem = 1e9; + for (var i = 0u; i < 8u; i++) { + let e = abs(elems[i]); + if e > 0.0 && e < min_elem { + min_elem = e; + } + } + out.min_elem = select(0.0, min_elem, min_elem < 1e8); + + return out; +} + +fn mod_tau(val: f32) -> f32 { + let m = val % TAU; + return select(m, m + TAU, m < 0.0); +} + +struct ClosestResult { + q: vec2, + t: f32, +} + +fn closest_point_ellipse(p_in: vec2, ab: vec2) -> ClosestResult { + let px = abs(p_in.x); + let py = abs(p_in.y); + let a = ab.x; + let b = ab.y; + if abs(a - b) < 1e-5 { + let len = length(p_in); + let q_circ = select(p_in, normalize(p_in) * a, len > 1e-6); + let ang = atan2(q_circ.y, q_circ.x); + let full_t = select(ang, ang + TAU, ang < 0.0); + return ClosestResult(q_circ, full_t); + } + + // Initial estimate from eccentric anomaly of radial projection + var t = atan2(py * a, px * b); + let k = a * a - b * b; + + // 3 iterations of Newton-Raphson for machine precision + for (var i = 0u; i < 3u; i++) { + let st = sin(t); + let ct = cos(t); + let f = k * st * ct - px * a * st + py * b * ct; + let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st; + if abs(f_prime) > 1e-7 { + t -= f / f_prime; + } + } + t = clamp(t, 0.0, 1.5707963267948966); + + let q_quad = vec2(a * cos(t), b * sin(t)); + let q = vec2( + select(-q_quad.x, q_quad.x, p_in.x >= 0.0), + select(-q_quad.y, q_quad.y, p_in.y >= 0.0) + ); + + var full_t = t; + if (p_in.x < 0.0) { + full_t = 3.141592653589793 - t; + } + if (p_in.y < 0.0) { + full_t = select(TAU - t, 3.141592653589793 + t, p_in.x < 0.0); + } + + return ClosestResult(q, full_t); +} + +fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { + let elems = array(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elems[i] != 0.0 { count = i + 1u; } + } + if count == 0u { + return true; + } + + let d = ((dist % pat_len) + pat_len) % pat_len; + var pos = 0.0f; + let dot_half = u.world_per_pixel * 0.75; + for (var i = 0u; i < count; i++) { + let elem = elems[i]; + if elem == 0.0 { + let dd = abs(d - pos); + if min(dd, pat_len - dd) <= dot_half { return true; } + } else if elem > 0.0 { + if d >= pos && d < pos + elem { return true; } + pos += elem; + } else { + pos += -elem; + } + } + return false; +} + +@fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + let ab = in.semi_axes; + let res = closest_point_ellipse(in.local_pos, ab); + let q = res.q; + let t = res.t; + + let sa = in.start_param; + let ea = in.end_param; + var sweep = mod_tau(ea - sa); + if sweep <= 1e-5 && abs(ea - sa) > 1e-5 { + sweep = TAU; + } + + let d_t = mod_tau(t - sa); + + var d_world: f32; + var arc_dist: f32; + + // Ramanujan approximation of ellipse perimeter for dash scaling + let h_val = ((ab.x - ab.y) * (ab.x - ab.y)) / max((ab.x + ab.y) * (ab.x + ab.y), 1e-6); + let perimeter = 3.141592653589793 * (ab.x + ab.y) * (1.0 + (3.0 * h_val) / max(10.0 + sqrt(max(4.0 - 3.0 * h_val, 0.0)), 1e-6)); + + if sweep >= TAU - 1e-5 || d_t <= sweep { + d_world = length(in.local_pos - q); + arc_dist = perimeter * (d_t / TAU); + } else { + let p_start = vec2(ab.x * cos(sa), ab.y * sin(sa)); + let p_end = vec2(ab.x * cos(ea), ab.y * sin(ea)); + let d_start = length(in.local_pos - p_start); + let d_end = length(in.local_pos - p_end); + if d_start < d_end { + d_world = d_start; + arc_dist = 0.0; + } else { + d_world = d_end; + arc_dist = perimeter * (sweep / TAU); + } + } + + // Isotropic quad screen-space filter width: + // Deriving from interpolated linear coordinates eliminates scanline derivative cancellations + let dx = dpdx(in.local_pos); + let dy = dpdy(in.local_pos); + let fw_len = sqrt(0.5 * (dot(dx, dx) + dot(dy, dy))); + let fw = max(select(u.world_per_pixel, fw_len, fw_len > 1e-6), 1e-6); + + let d_px = d_world / fw; + let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0); + if alpha_cov <= 0.0 { + discard; + } + + if in.pattern_length > 0.0 { + let pat_len = in.pattern_length * u.linetype_scale; + if in.min_elem * u.linetype_scale >= u.world_per_pixel && pat_len > 0.0 { + if !in_dash(arc_dist, pat_len, in.pat0, in.pat1) { + discard; + } + } + } + + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5) * alpha_cov; + return vec4(in.color.rgb, alpha); +} diff --git a/src/snap.rs b/src/snap.rs index 950b29615..515ee4278 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1778,6 +1778,7 @@ impl Snapper { .fold(f32::INFINITY, f32::min); (world, edge_d2) } + TangentGeom::PlanarEllipse { .. } => continue, }; let (tier, sub) = ( snap_tier(SnapType::Tangent), @@ -1814,6 +1815,7 @@ impl Snapper { radius: *radius, } } + TangentGeom::PlanarEllipse { .. } => unreachable!(), }; best = Some(SnapResult { world: world_pt, From dd061a55edccc059db3b886c2bda16fed15d8a22 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 13:55:16 +0100 Subject: [PATCH 10/71] perf(render): single-pass viewport wire classification and partitioning - Add fast classify_wire to bypass circle/ellipse extraction for the 95%+ of wires that are straight/polylines. - Implement single-pass partition_wires extracting regular wires, mesh edges, block wires, circles, and ellipses in one traversal. - Upload circle and ellipse instances directly from pre-extracted buffers in PersistentWireArena and build_wire_buffers without redundant iteration. --- src/scene/pipeline/mod.rs | 96 +++++++++++--------- src/scene/pipeline/wire_arena.rs | 148 ++++++++++++++++++++++++++++--- src/scene/view/render.rs | 19 ++-- 3 files changed, 200 insertions(+), 63 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index f6896df0b..abd1e9d5b 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2407,7 +2407,7 @@ impl Pipeline { ) } - /// Upload GPU-instanced analytical circles. + /// Upload GPU-instanced analytical circles from a wire slice. pub fn upload_circles( &mut self, device: &wgpu::Device, @@ -2425,6 +2425,16 @@ impl Pipeline { instances.push(inst); } } + self.upload_circles_from_instances(device, queue, &instances) + } + + /// Upload GPU-instanced analytical circles from pre-extracted instances. + pub fn upload_circles_from_instances( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + instances: &[CircleInstance], + ) -> Vec { if instances.is_empty() { vec![] } else { @@ -2432,12 +2442,12 @@ impl Pipeline { device, queue, "viewer.circles", - &instances, + instances, )] } } - /// Upload GPU-instanced analytical ellipses. + /// Upload GPU-instanced analytical ellipses from a wire slice. pub fn upload_ellipses( &mut self, device: &wgpu::Device, @@ -2455,6 +2465,16 @@ impl Pipeline { instances.push(inst); } } + self.upload_ellipses_from_instances(device, queue, &instances) + } + + /// Upload GPU-instanced analytical ellipses from pre-extracted instances. + pub fn upload_ellipses_from_instances( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + instances: &[EllipseInstance], + ) -> Vec { if instances.is_empty() { vec![] } else { @@ -2462,7 +2482,7 @@ impl Pipeline { device, queue, "viewer.ellipses", - &instances, + instances, )] } } @@ -2490,48 +2510,42 @@ impl Pipeline { // wire order — already sorted by draw order — is preserved; depth bias // and alpha blending both depend on it. Scissor and mesh-edge stay // grouping keys because the draw loop sets one scissor per batch and - // skips whole mesh-edge batches in shaded modes. - let is_mesh_edge = |w: &WireModel| !w.points.is_empty() && w.fill_is_3d; let mut batches: Vec = Vec::new(); let mut block_wires: Vec<&WireModel> = Vec::new(); let mut i = 0; while i < wires.len() { - if !wires[i].display_visible { - i += 1; - continue; + let kind = wire_arena::classify_wire(&wires[i]); + match kind { + wire_arena::WireKind::Invisible + | wire_arena::WireKind::Circle + | wire_arena::WireKind::Ellipse + | wire_arena::WireKind::Empty => { + i += 1; + continue; + } + wire_arena::WireKind::Block => { + block_wires.push(&wires[i]); + i += 1; + continue; + } + wire_arena::WireKind::Regular | wire_arena::WireKind::MeshEdge => { + let mesh_edge = kind == wire_arena::WireKind::MeshEdge; + let mut j = i + 1; + while j < wires.len() && wire_arena::classify_wire(&wires[j]) == kind { + j += 1; + } + let refs: Vec<&WireModel> = wires[i..j].iter().collect(); + batches.extend(WireGpu::from_run_refs( + device, + queue, + &refs, + depth_map, + mesh_edge, + self.wire_const_bgl.as_ref(), + )); + i = j; + } } - if wires[i].render_instance.is_some() { - block_wires.push(&wires[i]); - i += 1; - continue; - } - if circle_gpu::extract_circle_instance(&wires[i], 0.0).is_some() - || ellipse_gpu::extract_ellipse_instance(&wires[i], 0.0).is_some() - { - i += 1; - continue; - } - let mesh_edge = is_mesh_edge(&wires[i]); - let mut j = i + 1; - while j < wires.len() - && wires[j].display_visible - && wires[j].render_instance.is_none() - && circle_gpu::extract_circle_instance(&wires[j], 0.0).is_none() - && ellipse_gpu::extract_ellipse_instance(&wires[j], 0.0).is_none() - && is_mesh_edge(&wires[j]) == mesh_edge - { - j += 1; - } - let refs: Vec<&WireModel> = wires[i..j].iter().collect(); - batches.extend(WireGpu::from_run_refs( - device, - queue, - &refs, - depth_map, - mesh_edge, - self.wire_const_bgl.as_ref(), - )); - i = j; } let block_batches = BlockWireGpu::from_wires( device, diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 8af96ba2f..2c24e09bd 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -120,19 +120,147 @@ fn handle_of(w: &WireModel) -> Option { crate::scene::Scene::handle_from_wire_name(&w.name) } +/// Classification of a wire model for rendering pipelines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WireKind { + Invisible, + Block, + Circle, + Ellipse, + Regular, + MeshEdge, + Empty, +} + +/// Fast, branch-predicted classification of a wire model. +/// +/// Bypasses analytical circle/ellipse extraction for the 95%+ of wires that have +/// no tangent geometry or multiple tangent primitives, reducing overhead to ~2 ns. +#[inline] +pub fn classify_wire(wire: &WireModel) -> WireKind { + if !wire.display_visible { + return WireKind::Invisible; + } + if wire.render_instance.is_some() { + return WireKind::Block; + } + if wire.tangent_geoms.len() == 1 + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty() + { + match &wire.tangent_geoms[0] { + crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } + | crate::scene::model::wire_model::TangentGeom::Arc { .. } => { + if super::circle_gpu::extract_circle_instance(wire, 0.0).is_some() { + return WireKind::Circle; + } + } + crate::scene::model::wire_model::TangentGeom::PlanarEllipse { .. } => { + if super::ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_some() { + return WireKind::Ellipse; + } + } + _ => {} + } + } + if !wire.points.is_empty() { + if wire.fill_is_3d { + WireKind::MeshEdge + } else { + WireKind::Regular + } + } else { + WireKind::Empty + } +} + +/// Result of a single-pass partitioning of viewport wires. +pub struct PartitionedWires<'a> { + pub regular: Vec<&'a WireModel>, + pub mesh: Vec<&'a WireModel>, + pub instanced: Vec<&'a WireModel>, + pub circle_instances: Vec, + pub ellipse_instances: Vec, +} + +/// Single-pass classification and extraction of all viewport wire categories. +/// +/// Traverses the wire slice exactly once, eliminating redundant passes and duplicate +/// analytical extractions across split_wires, upload_block_wires, upload_circles, +/// and upload_ellipses. +pub fn partition_wires<'a>( + wires: &'a [WireModel], + depth_map: &rustc_hash::FxHashMap, +) -> PartitionedWires<'a> { + let mut regular = Vec::new(); + let mut mesh = Vec::new(); + let mut instanced = Vec::new(); + let mut circle_instances = Vec::new(); + let mut ellipse_instances = Vec::new(); + + for wire in wires { + if !wire.display_visible { + continue; + } + if wire.render_instance.is_some() { + instanced.push(wire); + continue; + } + let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); + if wire.tangent_geoms.len() == 1 + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty() + { + match &wire.tangent_geoms[0] { + crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } + | crate::scene::model::wire_model::TangentGeom::Arc { .. } => { + if let Some(inst) = super::circle_gpu::extract_circle_instance(wire, depth) { + circle_instances.push(inst); + continue; + } + } + crate::scene::model::wire_model::TangentGeom::PlanarEllipse { .. } => { + if let Some(inst) = super::ellipse_gpu::extract_ellipse_instance(wire, depth) { + ellipse_instances.push(inst); + continue; + } + } + _ => {} + } + } + if !wire.points.is_empty() { + if wire.fill_is_3d { + mesh.push(wire); + } else { + regular.push(wire); + } + } + } + + PartitionedWires { + regular, + mesh, + instanced, + circle_instances, + ellipse_instances, + } +} + /// Use the same regular/mesh partition for full uploads and changed runs. /// Instanced blocks are uploaded separately by `BlockWireGpu`. pub fn split_wires(wires: &[WireModel]) -> (Vec<&WireModel>, Vec<&WireModel>) { - wires - .iter() - .filter(|wire| { - wire.display_visible - && !wire.points.is_empty() - && wire.render_instance.is_none() - && super::circle_gpu::extract_circle_instance(wire, 0.0).is_none() - && super::ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none() - }) - .partition(|wire| !wire.fill_is_3d) + let mut regular = Vec::new(); + let mut mesh = Vec::new(); + for wire in wires { + match classify_wire(wire) { + WireKind::Regular => regular.push(wire), + WireKind::MeshEdge => mesh.push(wire), + _ => {} + } + } + (regular, mesh) } /// True when appending a new entity at the tail could change the image, so the diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 0c734c86f..b25fb4810 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -839,34 +839,29 @@ impl shader::Primitive for Primitive { gpus.extend(arena.wire_gpus()); } inner.gpu_wires = std::sync::Arc::new(gpus); - // Blocks keep their own cached geometry and placements. - let instanced: Vec<&crate::scene::WireModel> = vp_wires - .iter() - .filter(|w| w.render_instance.is_some()) - .collect(); + let partitioned = + wire_arena::partition_wires(&vp_wires, &draw_depths); inner.gpu_block_wires = std::sync::Arc::new( inner.upload_block_wires( device, queue, - &instanced, + &partitioned.instanced, &draw_depths, &mut pipeline.block_geometry, ), ); inner.gpu_circles = std::sync::Arc::new( - inner.upload_circles( + inner.upload_circles_from_instances( device, queue, - &vp_wires, - &draw_depths, + &partitioned.circle_instances, ), ); inner.gpu_ellipses = std::sync::Arc::new( - inner.upload_ellipses( + inner.upload_ellipses_from_instances( device, queue, - &vp_wires, - &draw_depths, + &partitioned.ellipse_instances, ), ); if _patched { From 1e118b2e905663ee32e283960ff93a89321be523 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:32:01 +0300 Subject: [PATCH 11/71] Complete exact INTERSECT behavior --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/commands/draw.rs | 52 ++-- src/app/model_ops.rs | 451 +++++++++++++++++++++++++++++- src/scene/convert/solid3d_tess.rs | 4 + src/scene/entity.rs | 3 + src/scene/model/solid_model.rs | 7 +- 8 files changed, 497 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d4975fe2..6ce25420d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,7 +882,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6a5e6fc2822f6af6b2611cd605b44437b35158f0#6a5e6fc2822f6af6b2611cd605b44437b35158f0" +source = "git+https://github.com/ramox81/cadkernel.git?rev=fb8946b7f051b67066d044fd5bb63681b64eaa35#fb8946b7f051b67066d044fd5bb63681b64eaa35" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 5ccd655d6..9778d19c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,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 = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "fb8946b7f051b67066d044fd5bb63681b64eaa35", 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 973bd0459..4a8c1df9f 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 = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "fb8946b7f051b67066d044fd5bb63681b64eaa35", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 5090140ef..4e7da2319 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -975,7 +975,9 @@ impl OpenCADStudio { "UNION" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { - let solid_count = { + let ready = if op == BoolOp::Intersect { + self.intersect_ready() + } else { let scene = &self.tabs[i].scene; scene .selected_handles_in_order() @@ -989,8 +991,9 @@ impl OpenCADStudio { }) .take(2) .count() + >= 2 }; - if solid_count < 2 { + if !ready { use crate::modules::draw::select::SelectObjectsCommand; let selection = SelectObjectsCommand::new(cmd); self.command_line.push_info(&selection.prompt()); @@ -1069,9 +1072,9 @@ impl OpenCADStudio { // REGION — convert selected closed boundaries (closed polylines / // circles) into Region entities (one wire loop each). "REGION" | "REG" => { - use acadrust::entities::{Region, Wire}; + use acadrust::entities::Region; use acadrust::types::Vector3; - let mut loops: Vec> = Vec::new(); + let mut regions = Vec::new(); for (_, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, @@ -1079,32 +1082,39 @@ impl OpenCADStudio { if pl.is_closed && pl.vertices.len() >= 3 ) || matches!(e, acadrust::EntityType::Circle(_)); if supported { - let Some(curve) = crate::entities::curve::entity_curve(e) else { + let Some((plane, loops, true)) = + crate::scene::model::presspull_model::profile_geometry(e) + else { continue; }; - loops.push( - crate::entities::curve::curve_points(&curve) - .into_iter() - .map(|point| Vector3::new(point[0], point[1], point[2])) - .collect(), + let Some(body) = cadkernel::brep::planar_region(plane, &loops) else { + continue; + }; + let mut region = Region::new(); + region.point_of_reference = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], ); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); } } - if loops.is_empty() { + if regions.is_empty() { self.command_line .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); } else { self.push_undo_snapshot(i, "REGION"); - let count = loops.len(); - for pts in loops { - let mut w = Wire::new(); - let first = pts.first().copied().unwrap_or(Vector3::new(0.0, 0.0, 0.0)); - w.points = pts; - let mut r = Region::new(); - r.point_of_reference = first; - r.wires = vec![w]; - r.common.layer = self.tabs[i].active_layer.clone(); - self.tabs[i].scene.add_entity(acadrust::EntityType::Region(r)); + let count = regions.len(); + let mut created = Vec::with_capacity(count); + for (region, body) in regions { + let handle = self.add_region_model(region, body); + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + return Some(iced::Task::none()); + } + created.push(handle); } self.tabs[i].dirty = true; self.command_line diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 38c287d23..d77dbe3c0 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,16 +1,166 @@ // Kernel B-rep solid modelling and exact ACIS persistence. use acadrust::{ - entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, + entities::{AcisData, Solid3D, SurfaceData, SurfaceKind, Wire}, + objects::SolidHistoryOperation, + EntityType, Handle, }; use cadkernel::brep::Body; use iced::Task; +use std::collections::HashMap; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; +#[derive(Clone, Copy, PartialEq, Eq)] +enum IntersectEntityKind { + Solid, + Region, + Surface, +} + +impl IntersectEntityKind { + fn from_entity(entity: &EntityType) -> Option { + Some(match entity { + EntityType::Solid3D(_) => Self::Solid, + EntityType::Region(_) => Self::Region, + EntityType::Surface(_) => Self::Surface, + _ => return None, + }) + } +} + +struct IntersectGroup { + kind: IntersectEntityKind, + plane: Option, + handles: Vec, +} + +type PreparedSolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + +enum PreparedIntersectOutcome { + Replace { + retained: Handle, + entity: EntityType, + body: Body, + display: PreparedSolidDisplay, + }, + Consume, +} + +struct PreparedIntersect { + handles: Vec, + outcome: PreparedIntersectOutcome, +} + +fn intersect_planar_body_plane(body: &Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let first_normal = glam::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; + }; + let Some(normal) = profile.plane.normal() else { + return false; + }; + first_normal.dot(glam::DVec3::from_array(normal)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) + }) + .then_some(first) +} + +fn intersect_coplanar_body(plane: cadkernel::space::Plane, body: &Body) -> bool { + let Some(other) = intersect_planar_body_plane(body) else { + return false; + }; + let Some(one_normal) = plane.normal().map(glam::DVec3::from_array) else { + return false; + }; + let Some(other_normal) = other.normal().map(glam::DVec3::from_array) else { + return false; + }; + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + one_normal.dot(other_normal).abs() >= 1.0 - 1e-9 + && plane + .distance_to(other.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) +} + +fn entity_with_intersection_body(mut source: EntityType, body: &Body) -> 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.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Region(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + 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, + Disjoint, +} + +fn intersect_bodies( + kind: IntersectEntityKind, + bodies: Vec, +) -> Result { + if kind == IntersectEntityKind::Solid { + let mut operands = bodies.into_iter(); + let mut result = operands + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for operand in operands { + result = solid_model::boolean_result(Bool::Intersect, &result, &operand)?; + if result.faces.is_empty() { + return Ok(IntersectBodyOutcome::Disjoint); + } + } + return Ok(IntersectBodyOutcome::Area(result)); + } + + let references = bodies.iter().collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + Ok(match cadkernel::brep::intersect_planar_regions(&bodies, tolerance)? { + cadkernel::brep::PlanarIntersection::Area(body) => IntersectBodyOutcome::Area(body), + cadkernel::brep::PlanarIntersection::Touching => IntersectBodyOutcome::Touching, + cadkernel::brep::PlanarIntersection::Disjoint => IntersectBodyOutcome::Disjoint, + }) +} + impl super::OpenCADStudio { /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( @@ -81,6 +231,29 @@ impl super::OpenCADStudio { handle } + pub(super) fn add_region_model( + &mut self, + mut region: acadrust::entities::Region, + body: Body, + ) -> Handle { + let i = self.active_tab; + region.wires = solid_model::edge_wires(&body); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { + self.command_line + .push_error(crate::t!("The region could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + region.set_sat_document(&document); + let Some(handle) = self.commit_entity_handle(EntityType::Region(region)) else { + return Handle::NULL; + }; + if !self.tabs[i].scene.register_solid_model(handle, body) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; let mut handles: Vec = self.tabs[i] @@ -100,6 +273,91 @@ impl super::OpenCADStudio { handles } + fn selected_intersect_handles(&self) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + scene + .document + .get_entity(*handle) + .and_then(IntersectEntityKind::from_entity) + .is_some() + }) + .collect() + } + + pub(super) fn intersect_ready(&self) -> bool { + let scene = &self.tabs[self.active_tab].scene; + let mut counts = [0usize; 3]; + for handle in scene.selected_handles_in_order() { + if scene.is_layer_locked(handle) { + continue; + } + let Some(kind) = scene + .document + .get_entity(handle) + .and_then(IntersectEntityKind::from_entity) + else { + continue; + }; + let index = match kind { + IntersectEntityKind::Solid => 0, + IntersectEntityKind::Region => 1, + IntersectEntityKind::Surface => 2, + }; + counts[index] += 1; + } + counts.into_iter().any(|count| count >= 2) + } + + fn intersect_groups( + &self, + handles: &[Handle], + bodies: &HashMap, + ) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + let mut groups = Vec::::new(); + for handle in handles { + let Some(kind) = scene + .document + .get_entity(*handle) + .and_then(IntersectEntityKind::from_entity) + else { + continue; + }; + let plane = (kind != IntersectEntityKind::Solid) + .then(|| intersect_planar_body_plane(&bodies[handle])) + .flatten(); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && if kind == IntersectEntityKind::Solid { + true + } else { + match (group.plane, plane) { + (Some(group_plane), Some(_)) => { + intersect_coplanar_body(group_plane, &bodies[handle]) + } + (None, None) => true, + _ => false, + } + } + }) { + group.handles.push(*handle); + } else { + groups.push(IntersectGroup { + kind, + plane, + handles: vec![*handle], + }); + } + } + groups.retain(|group| group.handles.len() >= 2); + groups + } + fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { let i = self.active_tab; let Some(document) = @@ -195,8 +453,199 @@ impl super::OpenCADStudio { Task::none() } + /// Intersect compatible selected solids, Regions, and coplanar Surfaces. + pub(super) fn solid_intersect(&mut self) -> Task { + let i = self.active_tab; + let mut handles = self.selected_intersect_handles(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + let bodies = handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .solid_models + .get(handle) + .cloned() + .map(|body| (*handle, body)) + }) + .collect::>(); + let groups = self.intersect_groups(&handles, &bodies); + if groups.is_empty() { + self.command_line.push_error( + crate::t!("INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type.") + .as_ref(), + ); + return Task::none(); + } + + let mut prepared = Vec::new(); + let mut retained_without_change = 0usize; + for group in groups { + let operands = group + .handles + .iter() + .map(|handle| bodies[handle].clone()) + .collect::>(); + let outcome = match intersect_bodies(group.kind, operands) { + Ok(outcome) => outcome, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected objects do not have a supported coplanar surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected coincident geometry is ambiguous.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected topology could not be intersected safely.") + .as_ref(), + ); + return Task::none(); + } + }; + + match outcome { + IntersectBodyOutcome::Area(body) => { + let retained = group.handles[0]; + let Some(source) = self.tabs[i].scene.document.get_entity(retained).cloned() + else { + return Task::none(); + }; + let Some(entity) = entity_with_intersection_body(source, &body) else { + self.command_line.push_error( + crate::t!("The INTERSECT result could not be encoded as ACIS.") + .as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(retained, &body) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("The INTERSECT result could not be displayed completely. The original objects were retained.") + .as_ref(), + ); + return Task::none(); + }; + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Replace { + retained, + entity, + body, + display, + }, + }); + } + IntersectBodyOutcome::Touching + if group.kind == IntersectEntityKind::Region + || group.kind == IntersectEntityKind::Surface => + { + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Consume, + }); + } + IntersectBodyOutcome::Disjoint + if group.kind == IntersectEntityKind::Region => + { + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Consume, + }); + } + IntersectBodyOutcome::Touching | IntersectBodyOutcome::Disjoint => { + retained_without_change += group.handles.len(); + } + } + } + + if prepared.is_empty() { + self.command_line.push_output( + crate::tf!( + "INTERSECT: no positive-area common result; %{count} original object(s) retained.", + count = retained_without_change + ) + .as_ref(), + ); + return Task::none(); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + self.push_undo_snapshot(i, "INTERSECT"); + let mut results = Vec::new(); + let mut consumed = Vec::new(); + for group in prepared { + match group.outcome { + PreparedIntersectOutcome::Replace { + retained, + entity, + body, + display, + } => { + self.tabs[i].scene.delete_solid_history(retained); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line.push_error( + crate::t!("INTERSECT: the retained object could not be updated.") + .as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(retained), + Some(EntityType::Solid3D(_)) + ) + { + let history = solid_history::brep_op(&body); + self.tabs[i].scene.create_solid_history(retained, history); + } + self.tabs[i] + .scene + .register_prepared_solid_model(retained, body, display); + results.push(retained); + consumed.extend( + group + .handles + .into_iter() + .filter(|handle| *handle != retained), + ); + } + PreparedIntersectOutcome::Consume => consumed.extend(group.handles), + } + } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + for handle in &results { + self.tabs[i].scene.select_entity(*handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!( + "INTERSECT: created %{count} result object(s).", + count = results.len() + ) + .as_ref(), + ); + Task::none() + } + /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + if op == BoolOp::Intersect { + return self.solid_intersect(); + } let i = self.active_tab; let handles = self.selected_solid_handles(); if handles.len() < 2 { diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index ebfa32df8..5095dd680 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -159,6 +159,10 @@ pub fn kernel_body(solid: &Solid3D) -> Option { kernel_acis_body(&solid.acis_data) } +pub fn kernel_region_body(region: &Region) -> Option { + kernel_acis_body(®ion.acis_data) +} + pub fn kernel_surface_body(surface: &Surface) -> Option { kernel_acis_body(&surface.acis_data) } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 1fe0317b4..a1dbbf3af 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -602,6 +602,9 @@ impl Scene { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } + Some(EntityType::Region(region)) => { + crate::scene::convert::solid3d_tess::kernel_region_body(region) + } Some(EntityType::Surface(surface)) => { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 24969c146..d6e8c6d9a 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -365,13 +365,18 @@ pub enum Bool { /// missing, and passing that on unchanged is the point: a half-done boolean /// looks finished. pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { + boolean_result(op, a, b).ok() +} + +/// Combine two solids while retaining the kernel's exact refusal reason. +pub fn boolean_result(op: Bool, a: &Body, b: &Body) -> Result { let how = match op { Bool::Union => brep::Operation::Union, Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; let tolerance = brep::operation_tolerance(&[a, b]); - brep::combine(a.clone(), b.clone(), how, tolerance).ok() + brep::combine(a.clone(), b.clone(), how, tolerance) } // ── Tessellation ──────────────────────────────────────────────────────────── From 690971c44e162113c2e41cbaa38397649882d0dc Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 14:32:55 +0100 Subject: [PATCH 12/71] Make tessellation memo and resident wire set zoom-independent With GPU analytical rendering handling circles, arcs, and ellipses, tessellation geometry is now camera- and zoom-independent. - Remove wpp from the tessellation memo guard hash to prevent clearing all cached entities when zooming across quantization bands. - Remove quantized_wpp from resident_wire_key and resident_wires_for so all zoom levels reuse the cached resident set directly with zero CPU re-tessellation. - Fix clippy slice_from_raw_parts lint in dwg-thumbnailer without suppression. - Update zoom test to assert Arc pointer equality across camera zooms. --- crates/dwg-thumbnailer/src/lib.rs | 2 +- src/scene/mod.rs | 66 +++++++++++++------------------ src/scene/paper.rs | 14 ++----- 3 files changed, 33 insertions(+), 49 deletions(-) diff --git a/crates/dwg-thumbnailer/src/lib.rs b/crates/dwg-thumbnailer/src/lib.rs index 736b8b0c0..28deb0399 100644 --- a/crates/dwg-thumbnailer/src/lib.rs +++ b/crates/dwg-thumbnailer/src/lib.rs @@ -259,7 +259,7 @@ pub unsafe extern "C" fn dwg_thumbnail_png( #[no_mangle] pub unsafe extern "C" fn dwg_thumbnail_free(ptr: *mut u8, len: usize) { if !ptr.is_null() && len != 0 { - drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1aa4c7269..b1aea6480 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2232,7 +2232,6 @@ impl Scene { self.annotation_all_visible(), None, None, - None, ); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); @@ -5162,9 +5161,9 @@ impl Scene { pub(super) fn model_tile_wires_arc( &self, _tile_idx: usize, - cam: &Camera, + _cam: &Camera, _cam_aspect: f32, - tile_pixel_height: f32, + _tile_pixel_height: f32, ) -> Arc> { // In a BEDIT block editor the resident set is the edited block's own // (block-local) entities; otherwise model space. (#261) @@ -5175,12 +5174,7 @@ impl Scene { &self.document, &self.document.header.current_annotation_scale, ); - let wpp = if tile_pixel_height > 0.0 { - Some((2.0 * cam.ortho_size()) / tile_pixel_height) - } else { - self.world_per_pixel() - }; - self.resident_wires_for(block, None, scale, None, None, wpp) + self.resident_wires_for(block, None, scale, None, None) } /// Unified static-hold wire builder — the ONE tessellation path every @@ -5229,7 +5223,6 @@ impl Scene { annotation_scale_handle: Option, frozen_layers: Option<&HashSet>, style_viewport: Option, - wpp: Option, ) -> Arc> { // Normalize an inert anno override away so distinct viewport scales // share one resident set when annotation can't change the wires. @@ -5246,7 +5239,6 @@ impl Scene { self.paper_bg_color }; let all_visible = self.annotation_all_visible(); - let quantized_wpp = Self::quantize_wpp(wpp); let key = self.resident_wire_key( block, bg, @@ -5255,7 +5247,6 @@ impl Scene { all_visible, frozen_layers, style_viewport, - quantized_wpp, ); { let sets = self.resident_wire_sets.borrow(); @@ -5280,18 +5271,17 @@ impl Scene { all_visible, frozen_layers, style_viewport, - quantized_wpp, ) { return arc; } - // Build once: full tessellation, no cull (region = None), zoom LOD - // bounded by quantized_wpp for every space. + // Build once: full tessellation, no cull, no zoom LOD — the resident + // set is zoom-independent (GPU analytical circles/arcs/ellipses). let t_tess = iced::time::Instant::now(); let mut wires = self.wires_for_block_culled( block, None, - quantized_wpp, + None, frozen_layers, anno_scale_override, annotation_scale_handle, @@ -5361,7 +5351,6 @@ impl Scene { all_visible: bool, frozen_layers: Option<&HashSet>, style_viewport: Option, - quantized_wpp: Option, ) -> u64 { let mut key: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = @@ -5376,9 +5365,9 @@ impl Scene { mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); mix(self.viewport_style_key(style_viewport)); - mix(quantized_wpp - .map(|w| w.to_bits() as u64) - .unwrap_or(u64::MAX)); + // quantized_wpp removed: GPU analytical rendering for circles/arcs/ + // ellipses makes tessellation zoom-independent, so the resident set is + // shared across all zoom levels. match frozen_layers { Some(frozen) => { let mut signature = 0u64; @@ -5491,7 +5480,6 @@ impl Scene { all_visible: bool, frozen_layers: Option<&HashSet>, style_viewport: Option, - quantized_wpp: Option, ) -> Option>> { if self.viewport_style_key(style_viewport) != 0 { return None; @@ -5575,7 +5563,7 @@ impl Scene { e, Some(&blk), None, - quantized_wpp, + None, anno_scale_override.is_some(), ); memo_updates.push((*h, Arc::new(raw.clone()))); @@ -5857,7 +5845,7 @@ impl Scene { } let layout_block = self.current_layout_block_handle(); let scale = self.paper_annotation_scale_handle(); - let base = self.resident_wires_for(layout_block, None, scale, None, None, None); + let base = self.resident_wires_for(layout_block, None, scale, None, None); let mut wires = (*base).clone(); // The overall "sheet" viewport now IS the paper view itself, so its own // border rectangle must not be drawn as an entity on the sheet. @@ -9112,7 +9100,10 @@ impl Scene { let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; - mix(wpp.map(|w| w.to_bits() as u64).unwrap_or(u64::MAX)); + // wpp removed from guard: GPU analytical rendering handles + // circles/arcs/ellipses, Point ignores wpp, and Light (the + // only remaining wpp consumer) is rare enough that its stale + // glyphs don't justify clearing every memoized entity on zoom. if let Some(v) = view_aabb { for c in v { mix(c.to_bits() as u64); @@ -10230,7 +10221,6 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", scale, None, None, - None, ); let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied()); let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| { @@ -11244,7 +11234,6 @@ mod layout_cache_tests { scale, None, None, - None, ); let generation = s.last_model_wire_gen.get(); let bounds = s.model_space_extents().unwrap(); @@ -11260,17 +11249,17 @@ mod layout_cache_tests { } #[test] - fn circle_wires_scale_with_zoom_level() { + fn resident_wires_are_zoom_independent_and_gpu_analytical() { let mut s = Scene::new(); let mut circle = acadrust::entities::Circle::default(); circle.radius = 100.0; let handle = s.add_entity(EntityType::Circle(circle)); - // Far camera: distance is large -> wpp is large + // Far camera: distance is large let mut cam_far = Camera::default(); cam_far.distance = 1000.0; - // Close camera: distance is small -> wpp is small + // Close camera: distance is small let mut cam_close = Camera::default(); cam_close.distance = 1.0; @@ -11279,19 +11268,20 @@ mod layout_cache_tests { .iter() .find(|w| w.name == handle.value().to_string()) .unwrap(); - let far_pts = circle_wire_far.points.len(); let wires_close = s.model_tile_wires_arc(0, &cam_close, 1.0, 1000.0); - let circle_wire_close = wires_close - .iter() - .find(|w| w.name == handle.value().to_string()) - .unwrap(); - let close_pts = circle_wire_close.points.len(); + // Zooming must reuse the exact same resident wire set (zero re-tessellation) assert!( - close_pts > far_pts, - "Close camera points ({close_pts}) should exceed far camera points ({far_pts})" + Arc::ptr_eq(&wires_far, &wires_close), + "Resident wires must be identical Arc across zoom levels" ); - assert!(close_pts > 500, "Close points ({close_pts}) should be > 500"); + // Circle must carry TangentGeom for GPU analytical rendering + assert_eq!(circle_wire_far.tangent_geoms.len(), 1); + assert!(matches!( + circle_wire_far.tangent_geoms[0], + crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } + | crate::scene::model::wire_model::TangentGeom::Circle { .. } + )); } } diff --git a/src/scene/paper.rs b/src/scene/paper.rs index ed6e75e38..450074122 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -819,7 +819,7 @@ impl Scene { fn model_wires_for_viewport( &self, vp_handle: Handle, - screen_height_px: f32, + _screen_height_px: f32, ) -> Arc> { use rustc_hash::FxHashSet as HSet; @@ -829,17 +829,12 @@ impl Scene { // wheel tick whenever the drawing contained one annotative object. // Explicit viewport annotation-scale changes still rebuild the resident // set; PSLTSCALE is a viewport GPU uniform. - let (frozen, wpp) = match self.document.get_entity(vp_handle) { + let frozen = match self.document.get_entity(vp_handle) { Some(EntityType::Viewport(vp)) => { let f: HSet = vp.frozen_layers.iter().cloned().collect(); - let w = if screen_height_px > 0.0 && vp.view_height > 0.0 { - Some((vp.view_height as f32) / screen_height_px) - } else { - None - }; - (f, w) + f } - _ => (HSet::default(), None), + _ => HSet::default(), }; let scale_handle = self.viewport_scale_handle(vp_handle); @@ -849,7 +844,6 @@ impl Scene { scale_handle, Some(&frozen), Some(vp_handle), - wpp, ) } From 73cd759ca448799b50e9e8b97d88c8c2d3b3169a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 14:43:38 +0100 Subject: [PATCH 13/71] Extend GPU analytical circle and arc rendering to block inserts - Emit transformed analytical curves (circles, arcs, ellipses) in block expansion as standalone wires rather than merging them into batched line strips. - Keep render_instance unset on analytical curves in block prototypes so they route directly to CircleGpu / EllipseGpu instead of BlockWireGpu. - Support TangentGeom::Circle in extract_circle_instance and wire partitioning. - Drop analytical tangents on wires clipped by xclip so clipped geometry falls back correctly to line segments. - Add unit test verifying block circles and arcs extract as analytical GPU instances. --- src/scene/cache/block_cache.rs | 155 +++++++++++++++++++++++++++++-- src/scene/mod.rs | 41 ++++++++ src/scene/pick/xclip.rs | 3 + src/scene/pipeline/circle_gpu.rs | 8 ++ src/scene/pipeline/wire_arena.rs | 6 +- 5 files changed, 202 insertions(+), 11 deletions(-) diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 9416faae6..f441228e6 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1108,7 +1108,7 @@ pub fn expand_insert( ); let mut first = first_batches.finalize(&name, selected, bg_color); for wire in &mut first { - if wire.render_instance.is_none() { + if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() { wire.render_instance = Some( crate::scene::model::instance_model::RenderInstance { source_id: crate::scene::model::instance_model::next_source_id(), @@ -1149,7 +1149,7 @@ pub fn expand_insert( if let Some(guard) = prototype_guard.as_mut() { let translation = transform_translation(&xform); for wire in &mut wires { - if wire.render_instance.is_none() { + if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() { wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance { source_id: crate::scene::model::instance_model::next_source_id(), translation, @@ -1314,6 +1314,20 @@ fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 { w.max(h) / world_per_pixel } +fn is_standalone_analytical_curve(wire: &WireModel) -> bool { + wire.tangent_geoms.len() == 1 + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty() + && matches!( + wire.tangent_geoms[0], + TangentGeom::Circle { .. } + | TangentGeom::PlanarCircle { .. } + | TangentGeom::Arc { .. } + | TangentGeom::PlanarEllipse { .. } + ) +} + struct ExpandCtx<'a> { cache: &'a BlockCache, ins_color: [f32; 4], @@ -1931,12 +1945,14 @@ fn expand_defn( .collect(); crate::scene::pick::xclip::clip_wires(&mut wires, &world_poly); for wire in &mut wires { - wire.render_instance = Some( - crate::scene::model::instance_model::RenderInstance { - source_id: crate::scene::model::instance_model::next_source_id(), - translation, - }, - ); + if !is_standalone_analytical_curve(wire) { + wire.render_instance = Some( + crate::scene::model::instance_model::RenderInstance { + source_id: crate::scene::model::instance_model::next_source_id(), + translation, + }, + ); + } } out.extra_wires.extend(wires.iter().cloned()); first = Some((wires, translation)); @@ -1984,7 +2000,7 @@ fn expand_defn( ); let mut wires = sub.finalize("", ctx.selected, ctx.bg_color); for wire in &mut wires { - if wire.render_instance.is_none() { + if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() { wire.render_instance = Some( crate::scene::model::instance_model::RenderInstance { source_id: crate::scene::model::instance_model::next_source_id(), @@ -2297,6 +2313,127 @@ fn emit_wire( lw.line_weight_px }; + // Analytical GPU circle / arc / ellipse fast path: + // If this LocalWire is a pure analytical curve (single tangent geometry, no fills/text/pick), + // and transforms cleanly with accum_xform into an analytical curve, emit it as a standalone + // wire in extra_wires. This lets partition_wires extract it into CircleGpu / EllipseGpu for + // pixel-perfect analytical rendering on the GPU instead of segmented lines. + if lw.tangent_geoms.len() == 1 + && lw.fill_tris.is_empty() + && lw.pick_tris.is_empty() + && lw.text_verts.is_empty() + { + if let Some(tangent) = transform_tangent(&lw.tangent_geoms[0], accum_xform) { + if matches!( + tangent, + TangentGeom::Circle { .. } + | TangentGeom::PlanarCircle { .. } + | TangentGeom::Arc { .. } + | TangentGeom::PlanarEllipse { .. } + ) { + let contrast_bg = lw.contrast_bg.unwrap_or(ctx.bg_color); + let color = if lw.canvas_color { + ctx.bg_color + } else if lw.preserve_color { + final_color + } else { + crate::scene::view::render::adapt_to_bg(final_color, contrast_bg) + }; + let bg_adapt: crate::scene::model::wire_model::BgAdapt = + (lw.canvas_color || !lw.preserve_color).then(|| { + Box::new(crate::scene::model::wire_model::BgAdaptInputs { + raw_color: final_color, + text_raw_colors: Vec::new(), + contrast_bg: lw.contrast_bg, + canvas_color: lw.canvas_color, + preserve_color: lw.preserve_color, + }) + }); + + let mut points = Vec::with_capacity(lw.points.len()); + let mut points_low = Vec::with_capacity(lw.points.len()); + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for (idx, p) in lw.points.iter().enumerate() { + let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]); + let point = accum_xform.apply(Vector3::new( + p[0] as f64 + pl[0] as f64, + p[1] as f64 + pl[1] as f64, + p[2] as f64 + pl[2] as f64, + )); + let (hx, lx) = WireModel::split_ds(point.x); + let (hy, ly) = WireModel::split_ds(point.y); + let (hz, lz) = WireModel::split_ds(point.z); + if hx < min_x { min_x = hx; } + if hy < min_y { min_y = hy; } + if hx > max_x { max_x = hx; } + if hy > max_y { max_y = hy; } + points.push([hx, hy, hz]); + points_low.push([lx, ly, lz]); + } + let aabb = if min_x.is_infinite() { + WireModel::UNBOUNDED_AABB + } else { + [min_x, min_y, max_x, max_y] + }; + let mut snap_pts = Vec::with_capacity(lw.snap_pts.len()); + for (p, hint) in &lw.snap_pts { + let v = accum_xform.apply(Vector3::new(p.x, p.y, p.z)); + snap_pts.push((glam::DVec3::new(v.x, v.y, v.z), *hint)); + } + let mut key_vertices = Vec::with_capacity(lw.key_vertices.len()); + for p in &lw.key_vertices { + let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2])); + key_vertices.push([v.x, v.y, v.z]); + } + let local_depth = if d_range != (0.0, 1.0) { + Some(d_range.0) + } else { + None + }; + + let wire = WireModel { + bg_adapt, + point_marker: lw.point_marker, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: local_depth, + display_visible: !lw.hide_unselected || ctx.selected, + plot_visible: lw.plot_visible, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: String::new(), + points, + points_low, + color, + selected: ctx.selected, + pattern_length: final_pat_len, + pattern: final_pat, + line_weight_px: final_lw_px, + aci: final_aci, + snap_pts, + tangent_geoms: vec![tangent], + key_vertices, + aabb, + plinegen: lw.plinegen, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + }; + out.extra_wires.push(wire); + return; + } + } + } + let station_values = pattern_station_values(&lw.pattern_stations, lw.points.len()); let station_map = decode_pattern_station_map(&lw.pattern_stations, lw.points.len()); let transformed_stations = station_values.and_then(|(values, _)| { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b1aea6480..2d06cabcb 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11284,4 +11284,45 @@ mod layout_cache_tests { | crate::scene::model::wire_model::TangentGeom::Circle { .. } )); } + + #[test] + fn block_circles_and_arcs_extract_as_analytical_gpu_instances() { + let mut s = Scene::new(); + // Create initial entities + let mut circle = acadrust::entities::Circle::default(); + circle.radius = 50.0; + let c_h = s.add_entity(EntityType::Circle(circle)); + + let mut arc = acadrust::entities::Arc::default(); + arc.radius = 25.0; + arc.start_angle = 0.0; + arc.end_angle = 3.14159; + let a_h = s.add_entity(EntityType::Arc(arc)); + + let line = acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(100.0, 100.0, 0.0), + ); + let l_h = s.add_entity(EntityType::Line(line)); + + // Create block and place insert at (200, 300, 0) + let xform = acadrust::types::Transform::from_translation(acadrust::types::Vector3::new(200.0, 300.0, 0.0)); + let _ = s.create_block_from_entities( + &[c_h, a_h, l_h], + "TEST_ANALYTICAL_BLOCK", + &acadrust::types::Transform::identity(), + &xform, + ).unwrap(); + + let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + // Both the circle and the arc inside the block must be extracted as analytical GPU circle instances! + assert!( + partitioned.circle_instances.len() >= 2, + "Expected at least 2 analytical circle instances from block, got {}", + partitioned.circle_instances.len() + ); + } } diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 26f0b2982..0378dc7a7 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -197,6 +197,9 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { } else { w.pattern_stations.clear(); } + if !w.tangent_geoms.is_empty() { + w.tangent_geoms.clear(); + } } if !w.fill_tris.is_empty() { let local: Vec<[f32; 3]> = (0..w.fill_tris.len()) diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index 8c32369a0..a84929641 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -215,6 +215,14 @@ pub fn extract_circle_instance( return None; } let (center, axis_x, axis_y, radius, start_angle, end_angle) = match wire.tangent_geoms[0] { + crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => ( + [center[0] as f64, center[1] as f64, center[2] as f64], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + radius as f64, + 0.0f32, + std::f32::consts::TAU, + ), crate::scene::model::wire_model::TangentGeom::PlanarCircle { center, axis_x, diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 2c24e09bd..b7c6c89aa 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -150,7 +150,8 @@ pub fn classify_wire(wire: &WireModel) -> WireKind { && wire.text_verts.is_empty() { match &wire.tangent_geoms[0] { - crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } + crate::scene::model::wire_model::TangentGeom::Circle { .. } + | crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } | crate::scene::model::wire_model::TangentGeom::Arc { .. } => { if super::circle_gpu::extract_circle_instance(wire, 0.0).is_some() { return WireKind::Circle; @@ -214,7 +215,8 @@ pub fn partition_wires<'a>( && wire.text_verts.is_empty() { match &wire.tangent_geoms[0] { - crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } + crate::scene::model::wire_model::TangentGeom::Circle { .. } + | crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } | crate::scene::model::wire_model::TangentGeom::Arc { .. } => { if let Some(inst) = super::circle_gpu::extract_circle_instance(wire, depth) { circle_instances.push(inst); From 822b0afce5e14b3d7bd5b0cc91230a481c831d47 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 14:48:56 +0100 Subject: [PATCH 14/71] Convert polyline bulge segments to analytical TangentGeom::Arc - Add bulge_arc_to_tangent helper to convert 2D BulgeArc into a 3D TangentGeom::Arc preserving center, plane axes, radius, and CCW sweep angles. - Replace full TangentGeom::Circle emission with TangentGeom::Arc across LwPolyline and Polyline2D paths. - Enables single-arc polylines to be extracted and rendered as analytical GPU arcs with circle.wgsl. - Add unit test verifying single bulge polyline extracts as analytical GPU arc. --- src/entities/common.rs | 24 ++++++++++++++++++++++++ src/entities/lwpolyline.rs | 25 +++++-------------------- src/entities/polyline.rs | 21 +++++---------------- src/scene/mod.rs | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 4c8c98f6c..1f837daca 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -702,6 +702,30 @@ pub fn parse_angle_deg(value: &str) -> Option { /// there is one obvious place to see that the maths moved out. pub use cadkernel::geom2d::BulgeArc; +/// Convert a 2D BulgeArc into a 3D TangentGeom::Arc with its world center, +/// plane axes, radius, and counter-clockwise start/end sweep angles. +pub fn bulge_arc_to_tangent( + arc: &BulgeArc, + to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), + normal: (f64, f64, f64), +) -> crate::scene::model::wire_model::TangentGeom { + let (cwx, cwy, cwz) = to_wcs(arc.center[0], arc.center[1]); + let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); + let (sa, ea) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + crate::scene::model::wire_model::TangentGeom::Arc { + center: [cwx, cwy, cwz], + axis_x: [ax.0, ax.1, ax.2], + axis_y: [ay.0, ay.1, ay.2], + radius: arc.radius, + start_angle: sa, + end_angle: ea, + } +} + /// Triangulate the solid bands a `wide_fills` returns into the flat WCS f64 /// triangle list `RenderEntity::pick_tris` carries, so a wide polyline is /// selectable across the band it draws and not just along its centreline. diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 77a185326..8b125fe39 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -351,11 +351,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); for s in arc .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() @@ -407,11 +403,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { [v1.location.x, v1.location.y], v0.bulge, ) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } } let (pts, widths) = crate::entities::common::tapered_band_points( @@ -470,11 +462,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { } let (wx1, wy1, wz1) = to_wcs(ox1, oy1); kv.push([wx1, wy1, wz1]); - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } if i + 1 < seg_count { pts.push([f64::NAN; 3]); @@ -565,11 +553,8 @@ fn centerline_metadata( [end.location.x, end.location.y], start.bulge, ) { - let center = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [center.0 as f32, center.1 as f32, center.2 as f32], - radius: arc.radius as f32, - }); + let normal = (pline.normal.x, pline.normal.y, pline.normal.z); + tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal)); } if index == 0 { key_vertices.push([p0.0, p0.1, p0.2]); diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index d9cd71404..de67a35dd 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -248,7 +248,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { ); } let (tangent_geoms, key_vertices) = - centerline_metadata_2d(verts, pl.is_closed(), &to_wcs); + centerline_metadata_2d(verts, pl.is_closed(), &to_wcs, normal); return RenderEntity { pick_tris: Vec::new(), object: RenderObject::BoundaryLines { @@ -296,11 +296,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); for s in arc .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() @@ -378,11 +374,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { [v1.location.x, v1.location.y], bulge, ) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } if i == 0 { @@ -464,6 +456,7 @@ fn centerline_metadata_2d( verts: &[acadrust::entities::Vertex2D], closed: bool, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), + normal: (f64, f64, f64), ) -> (Vec, Vec<[f64; 3]>) { let count = verts.len(); let segment_count = if closed { @@ -488,11 +481,7 @@ fn centerline_metadata_2d( [end.location.x, end.location.y], start.bulge, ) { - let center = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [center.0 as f32, center.1 as f32, center.2 as f32], - radius: arc.radius as f32, - }); + tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal)); } if index == 0 { key_vertices.push([p0.0, p0.1, p0.2]); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2d06cabcb..2d90e9655 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11325,4 +11325,37 @@ mod layout_cache_tests { partitioned.circle_instances.len() ); } + + #[test] + fn single_bulge_polyline_extracts_as_analytical_gpu_arc() { + let mut s = Scene::new(); + let mut pline = acadrust::entities::LwPolyline::new(); + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(0.0, 0.0), + bulge: 1.0, // semicircle + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(100.0, 0.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + ]; + let _ = s.add_entity(EntityType::LwPolyline(pline)); + + let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + assert_eq!( + partitioned.circle_instances.len(), + 1, + "Single bulge arc polyline must be extracted as analytical GPU arc instance" + ); + } } From 6e01d464e50495143b8f69be5b239a6629f54daa Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 14:52:58 +0100 Subject: [PATCH 15/71] fix(workspace): resolve clippy warnings in ocs_plugin_api and ocs_doc_api --- crates/ocs_doc_api/src/executor.rs | 5 +---- crates/ocs_plugin_api/src/host.rs | 7 +++++-- crates/ocs_plugin_api/src/ipc/proxy.rs | 11 ++--------- crates/ocs_plugin_api/src/process/v4.rs | 10 ++++++---- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index b65443279..5f8cbce0f 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -450,10 +450,7 @@ fn profile_curves( b.profile_curves(profile) } -/// Validate a `Curve2Spec`'s fallible conditions BEFORE mutation (the same rules -/// the host's `curve_spec_to_entity` enforces), so `CreateMany` stays all-or-nothing. -/// Currently: ellipse minor/major ratio must be in (0, 1]. - +/// Validate that an entity exists before performing an operation. fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { if b.entity_exists(id) { Ok(()) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index b43bacd8a..dfc2acbf4 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -746,10 +746,13 @@ mod repl { error: Option, error_type: Option, traceback: Option, - line_number: Option, - column_number: Option, + position: Option<(u32, u32)>, duration_ms: f64, ) -> Self { + let (line_number, column_number) = match position { + Some((l, c)) => (Some(l), Some(c)), + None => (None, None), + }; Self { success, output, diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index e7729fcd8..6b5172b08 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -261,20 +261,13 @@ pub fn run_request_proxy( token: [u8; PROXY_TOKEN_LEN], ) -> std::io::Result<()> { for stream in listener.incoming() { - let mut stream = match stream { - Ok(s) => s, - Err(e) => return Err(e), - }; + let mut stream = stream?; if !verify_token(&mut stream, &token) { continue; } let sender = Arc::clone(&sender); std::thread::spawn(move || { - loop { - let req: PluginRequest = match recv_framed(&mut stream) { - Ok(r) => r, - Err(_) => break, - }; + while let Ok(req) = recv_framed(&mut stream) { let resp = match sender.request(req) { Ok(r) => r, Err(e) => PluginResponse::Error(e.to_string()), diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index d2fc60154..05e7dd490 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -75,10 +75,12 @@ fn request_kind(req: &HostRequest) -> &'static str { } } +type DeferredRequest = (u64, Option, Box); + pub(crate) struct V4Connection { shared: Arc, incoming: Mutex>, - deferred: Mutex, Box)>>, + deferred: Mutex>, call_lock: Mutex<()>, next_id: AtomicU64, reader_handle: Mutex>>, @@ -199,7 +201,7 @@ impl V4Connection { tab_id, payload, }) => { - if tab_id.map_or(true, |request_tab| request_tab == host.tab_id()) { + if tab_id.is_none_or(|request_tab| request_tab == host.tab_id()) { self.respond_to_plugin_request( host, rid, @@ -251,7 +253,7 @@ impl V4Connection { let mut deferred = self.deferred.lock().unwrap_or_else(|e| e.into_inner()); let mut waiting = VecDeque::new(); while let Some((id, tab_id, payload)) = deferred.pop_front() { - if tab_id.map_or(true, |request_tab| request_tab == current_tab_id) { + if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) { ready.push_back((id, payload)); } else { waiting.push_back((id, tab_id, payload)); @@ -271,7 +273,7 @@ impl V4Connection { tab_id, payload, }) => { - if tab_id.map_or(true, |request_tab| request_tab == current_tab_id) { + if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) { self.respond_to_plugin_request( host, id, From 75ec08dc5589bf57dcae6b5fdbdb8daf507025fc Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 14:59:50 +0100 Subject: [PATCH 16/71] feat(render): GPU analytical curve extraction for multi-arc polylines and blocks --- src/scene/cache/block_cache.rs | 27 +++++++++------ src/scene/mod.rs | 40 ++++++++++++++++++++++ src/scene/pipeline/circle_gpu.rs | 57 ++++++++++++++++++++++++------- src/scene/pipeline/ellipse_gpu.rs | 52 ++++++++++++++++++++++------ src/scene/pipeline/mod.rs | 52 ++++++++++++++++------------ src/scene/pipeline/wire_arena.rs | 46 ++++++++----------------- 6 files changed, 186 insertions(+), 88 deletions(-) diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index f441228e6..307a9a526 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -2318,19 +2318,26 @@ fn emit_wire( // and transforms cleanly with accum_xform into an analytical curve, emit it as a standalone // wire in extra_wires. This lets partition_wires extract it into CircleGpu / EllipseGpu for // pixel-perfect analytical rendering on the GPU instead of segmented lines. - if lw.tangent_geoms.len() == 1 + if !lw.tangent_geoms.is_empty() && lw.fill_tris.is_empty() && lw.pick_tris.is_empty() && lw.text_verts.is_empty() { - if let Some(tangent) = transform_tangent(&lw.tangent_geoms[0], accum_xform) { - if matches!( - tangent, - TangentGeom::Circle { .. } - | TangentGeom::PlanarCircle { .. } - | TangentGeom::Arc { .. } - | TangentGeom::PlanarEllipse { .. } - ) { + let transformed_tangents: Option> = lw + .tangent_geoms + .iter() + .map(|tg| transform_tangent(tg, accum_xform)) + .collect(); + if let Some(tangents) = transformed_tangents { + if tangents.iter().all(|tangent| { + matches!( + tangent, + TangentGeom::Circle { .. } + | TangentGeom::PlanarCircle { .. } + | TangentGeom::Arc { .. } + | TangentGeom::PlanarEllipse { .. } + ) + }) { let contrast_bg = lw.contrast_bg.unwrap_or(ctx.bg_color); let color = if lw.canvas_color { ctx.bg_color @@ -2421,7 +2428,7 @@ fn emit_wire( line_weight_px: final_lw_px, aci: final_aci, snap_pts, - tangent_geoms: vec![tangent], + tangent_geoms: tangents, key_vertices, aabb, plinegen: lw.plinegen, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2d90e9655..71d6e031d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11358,4 +11358,44 @@ mod layout_cache_tests { "Single bulge arc polyline must be extracted as analytical GPU arc instance" ); } + + #[test] + fn multi_bulge_polyline_extracts_as_analytical_gpu_arcs() { + let mut s = Scene::new(); + let mut pline = acadrust::entities::LwPolyline::new(); + // A circle represented as a closed 2-vertex polyline with two semicircle bulges (standard CAD polyline circle) + pline.is_closed = true; + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(0.0, 0.0), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(100.0, 0.0), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + let _ = s.add_entity(EntityType::LwPolyline(pline)); + + let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + assert_eq!( + partitioned.circle_instances.len(), + 2, + "Multi-bulge closed polyline circle must be extracted as 2 analytical GPU arc instances" + ); + assert_eq!( + partitioned.regular.len(), + 0, + "Multi-bulge pure arc polyline must not be sent to regular line arena" + ); + } } diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index a84929641..92d7332fb 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -199,22 +199,13 @@ pub fn create_pipelines( (circle_pipeline, circle_xray_pipeline) } -/// Helper to extract an analytical circle or circular arc instance from a `WireModel`. -pub fn extract_circle_instance( +/// Helper to extract an analytical circle or circular arc instance from a single `TangentGeom`. +pub fn extract_circle_instance_from_geom( + geom: &crate::scene::model::wire_model::TangentGeom, wire: &crate::scene::WireModel, draw_depth: f32, ) -> Option { - // Only thin 2D planar circles or circular arcs have exactly 1 PlanarCircle / Arc tangent geometry - // and no 3D mesh fills or text quads. - if wire.tangent_geoms.len() != 1 - || !wire.fill_tris.is_empty() - || !wire.pick_tris.is_empty() - || !wire.text_verts.is_empty() - || wire.render_instance.is_some() - { - return None; - } - let (center, axis_x, axis_y, radius, start_angle, end_angle) = match wire.tangent_geoms[0] { + let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom { crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => ( [center[0] as f64, center[1] as f64, center[2] as f64], [1.0, 0.0, 0.0], @@ -271,6 +262,46 @@ pub fn extract_circle_instance( }) } +/// Helper to extract all analytical circle/arc instances from a `WireModel`. +/// +/// Returns `Some(instances)` if the wire is non-empty, contains solely analytical +/// circle/arc tangent geometries, and has no mesh/pick fills, text, or block instance. +pub fn extract_circle_instances( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option> { + if wire.tangent_geoms.is_empty() + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + || wire.render_instance.is_some() + { + return None; + } + + let mut instances = Vec::with_capacity(wire.tangent_geoms.len()); + for geom in &wire.tangent_geoms { + if let Some(inst) = extract_circle_instance_from_geom(geom, wire, draw_depth) { + instances.push(inst); + } else { + return None; + } + } + Some(instances) +} + +/// Helper to extract a single analytical circle or circular arc instance from a `WireModel`. +pub fn extract_circle_instance( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + if wire.tangent_geoms.len() == 1 { + extract_circle_instances(wire, draw_depth).and_then(|mut v| v.pop()) + } else { + None + } +} + fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { let hx = x as f32; let lx = (x - hx as f64) as f32; diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs index 1c6b0765c..1291be8f5 100644 --- a/src/scene/pipeline/ellipse_gpu.rs +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -199,19 +199,12 @@ pub fn create_pipelines( (ellipse_pipeline, ellipse_xray_pipeline) } -/// Helper to extract an analytical ellipse instance from a `WireModel`. -pub fn extract_ellipse_instance( +/// Helper to extract an analytical ellipse instance from a single `TangentGeom`. +pub fn extract_ellipse_instance_from_geom( + geom: &crate::scene::model::wire_model::TangentGeom, wire: &crate::scene::WireModel, draw_depth: f32, ) -> Option { - if wire.tangent_geoms.len() != 1 - || !wire.fill_tris.is_empty() - || !wire.pick_tris.is_empty() - || !wire.text_verts.is_empty() - || wire.render_instance.is_some() - { - return None; - } let crate::scene::model::wire_model::TangentGeom::PlanarEllipse { center, major_axis, @@ -219,7 +212,7 @@ pub fn extract_ellipse_instance( minor_axis_ratio, start_param, end_param, - } = wire.tangent_geoms[0] else { + } = *geom else { return None; }; @@ -254,6 +247,43 @@ pub fn extract_ellipse_instance( }) } +/// Helper to extract all analytical ellipse instances from a `WireModel`. +pub fn extract_ellipse_instances( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option> { + if wire.tangent_geoms.is_empty() + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + || wire.render_instance.is_some() + { + return None; + } + + let mut instances = Vec::with_capacity(wire.tangent_geoms.len()); + for geom in &wire.tangent_geoms { + if let Some(inst) = extract_ellipse_instance_from_geom(geom, wire, draw_depth) { + instances.push(inst); + } else { + return None; + } + } + Some(instances) +} + +/// Helper to extract a single analytical ellipse instance from a `WireModel`. +pub fn extract_ellipse_instance( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + if wire.tangent_geoms.len() == 1 { + extract_ellipse_instances(wire, draw_depth).and_then(|mut v| v.pop()) + } else { + None + } +} + fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { let hx = x as f32; let lx = (x - hx as f64) as f32; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index abd1e9d5b..9e27c2a00 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2421,8 +2421,8 @@ impl Pipeline { continue; } let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(inst) = circle_gpu::extract_circle_instance(wire, depth) { - instances.push(inst); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + instances.extend(insts); } } self.upload_circles_from_instances(device, queue, &instances) @@ -2461,8 +2461,8 @@ impl Pipeline { continue; } let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) { - instances.push(inst); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + instances.extend(insts); } } self.upload_ellipses_from_instances(device, queue, &instances) @@ -2656,18 +2656,22 @@ 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(mut inst) = circle_gpu::extract_circle_instance(wire, depth) { - if let Some(tint) = selected_tint { - inst.color = tint; + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + for mut inst in insts { + if let Some(tint) = selected_tint { + inst.color = tint; + } + selected_circles.push(inst); } - selected_circles.push(inst); } } for &wire in &hover_wires { let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(mut inst) = circle_gpu::extract_circle_instance(wire, depth) { - inst.color = WireModel::HOVER; - selected_circles.push(inst); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + for mut inst in insts { + inst.color = WireModel::HOVER; + selected_circles.push(inst); + } } } self.gpu_selected_circles = if selected_circles.is_empty() { @@ -2684,18 +2688,22 @@ 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(mut inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) { - if let Some(tint) = selected_tint { - inst.color = tint; + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + for mut inst in insts { + if let Some(tint) = selected_tint { + inst.color = tint; + } + selected_ellipses.push(inst); } - selected_ellipses.push(inst); } } for &wire in &hover_wires { let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(mut inst) = ellipse_gpu::extract_ellipse_instance(wire, depth) { - inst.color = WireModel::HOVER; - selected_ellipses.push(inst); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + for mut inst in insts { + inst.color = WireModel::HOVER; + selected_ellipses.push(inst); + } } } self.gpu_selected_ellipses = if selected_ellipses.is_empty() { @@ -2714,8 +2722,8 @@ impl Pipeline { .copied() .filter(|wire| { wire.render_instance.is_none() - && circle_gpu::extract_circle_instance(wire, 0.0).is_none() - && ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none() + && circle_gpu::extract_circle_instances(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none() }) .collect(); let hover_regular: Vec<&WireModel> = hover_wires @@ -2723,8 +2731,8 @@ impl Pipeline { .copied() .filter(|wire| { wire.render_instance.is_none() - && circle_gpu::extract_circle_instance(wire, 0.0).is_none() - && ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_none() + && circle_gpu::extract_circle_instances(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none() }) .collect(); let selected_blocks: Vec<&WireModel> = selected_wires diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index b7c6c89aa..d4e927da4 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -144,25 +144,16 @@ pub fn classify_wire(wire: &WireModel) -> WireKind { if wire.render_instance.is_some() { return WireKind::Block; } - if wire.tangent_geoms.len() == 1 + if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() && wire.pick_tris.is_empty() && wire.text_verts.is_empty() { - match &wire.tangent_geoms[0] { - crate::scene::model::wire_model::TangentGeom::Circle { .. } - | crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } - | crate::scene::model::wire_model::TangentGeom::Arc { .. } => { - if super::circle_gpu::extract_circle_instance(wire, 0.0).is_some() { - return WireKind::Circle; - } - } - crate::scene::model::wire_model::TangentGeom::PlanarEllipse { .. } => { - if super::ellipse_gpu::extract_ellipse_instance(wire, 0.0).is_some() { - return WireKind::Ellipse; - } - } - _ => {} + if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() { + return WireKind::Circle; + } + if super::ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_some() { + return WireKind::Ellipse; } } if !wire.points.is_empty() { @@ -209,27 +200,18 @@ pub fn partition_wires<'a>( continue; } let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); - if wire.tangent_geoms.len() == 1 + if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() && wire.pick_tris.is_empty() && wire.text_verts.is_empty() { - match &wire.tangent_geoms[0] { - crate::scene::model::wire_model::TangentGeom::Circle { .. } - | crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } - | crate::scene::model::wire_model::TangentGeom::Arc { .. } => { - if let Some(inst) = super::circle_gpu::extract_circle_instance(wire, depth) { - circle_instances.push(inst); - continue; - } - } - crate::scene::model::wire_model::TangentGeom::PlanarEllipse { .. } => { - if let Some(inst) = super::ellipse_gpu::extract_ellipse_instance(wire, depth) { - ellipse_instances.push(inst); - continue; - } - } - _ => {} + if let Some(insts) = super::circle_gpu::extract_circle_instances(wire, depth) { + circle_instances.extend(insts); + continue; + } + if let Some(insts) = super::ellipse_gpu::extract_ellipse_instances(wire, depth) { + ellipse_instances.extend(insts); + continue; } } if !wire.points.is_empty() { From e603ef6104ad43c39a37fae9f1aed49d04b1a2a0 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 16:14:17 +0100 Subject: [PATCH 17/71] test(bench): add end-to-end analytical rendering benchmark --- src/scene/mod.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 71d6e031d..278a0f17d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11398,4 +11398,128 @@ mod layout_cache_tests { "Multi-bulge pure arc polyline must not be sent to regular line arena" ); } + + #[test] + fn bench_analytical_rendering() { + use std::time::Instant; + use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, LwPolyline, LwVertex}; + use acadrust::types::{Vector2, Vector3}; + + let n_circles = 5000; + let n_arcs = 5000; + let n_ellipses = 2000; + let n_polylines = 2000; + let total_entities = n_circles + n_arcs + n_ellipses + n_polylines; + + let mut scene = Scene::new(); + for i in 0..n_circles { + let x = (i % 100) as f64 * 50.0; + let y = (i / 100) as f64 * 50.0; + let mut circle = Circle::default(); + circle.center = Vector3::new(x, y, 0.0); + circle.radius = 10.0 + (i % 20) as f64; + scene.add_entity(EntityType::Circle(circle)); + } + + for i in 0..n_arcs { + let x = (i % 100) as f64 * 50.0 + 25.0; + let y = (i / 100) as f64 * 50.0; + let mut arc = AcadArc::default(); + arc.center = Vector3::new(x, y, 0.0); + arc.radius = 15.0; + arc.start_angle = ((i % 8) as f64) * 0.25 * std::f64::consts::PI; + arc.end_angle = arc.start_angle + 0.75 * std::f64::consts::PI; + scene.add_entity(EntityType::Arc(arc)); + } + + for i in 0..n_ellipses { + let x = (i % 50) as f64 * 100.0; + let y = (i / 50) as f64 * 100.0 + 5000.0; + let mut el = Ellipse::default(); + el.center = Vector3::new(x, y, 0.0); + el.major_axis = Vector3::new(20.0, 0.0, 0.0); + el.minor_axis_ratio = 0.5; + el.start_parameter = 0.0; + el.end_parameter = std::f64::consts::TAU; + scene.add_entity(EntityType::Ellipse(el)); + } + + for i in 0..n_polylines { + let x = (i % 50) as f64 * 100.0 + 50.0; + let y = (i / 50) as f64 * 100.0 + 5000.0; + let mut pline = LwPolyline::new(); + pline.is_closed = true; + pline.vertices = vec![ + LwVertex { + location: Vector2::new(x, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 30.0, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + let cam = Camera::default(); + let t_tess_start = Instant::now(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let tess_duration = t_tess_start.elapsed(); + + let total_wires = wires.len(); + let mut chord_points = 0usize; + let mut analytical_tangents = 0usize; + for w in wires.iter() { + chord_points += w.points.len(); + analytical_tangents += w.tangent_geoms.len(); + } + + let depths = rustc_hash::FxHashMap::default(); + let t_part_start = Instant::now(); + let iters = 100; + let mut part = None; + for _ in 0..iters { + part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths)); + } + let part_duration = t_part_start.elapsed() / (iters as u32); + let p = part.unwrap(); + + // Zoom simulation + let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; + let t_zoom_start = Instant::now(); + let mut dynamic_cam = Camera::default(); + for &zoom in zoom_levels.iter().cycle().take(100) { + dynamic_cam.distance = 1000.0 / zoom; + let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); + } + let zoom_100_duration = t_zoom_start.elapsed(); + let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0; + + let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0); + let inst_vram_mb = (p.circle_instances.len() * 128 + p.ellipse_instances.len() * 144) as f64 / (1024.0 * 1024.0); + + println!("\n=== BENCHMARK REPORT: CURRENT HEAD (WITH GPU ANALYTICAL & OPTIMIZATIONS) ==="); + println!("Curved Entities Tested: {total_entities}"); + println!("Initial Wire Build Time: {:.2?}", tess_duration); + println!("Total Wires: {total_wires}"); + println!("Tessellated Chord Vertices: {chord_points}"); + println!("Analytical Tangent Geometries: {analytical_tangents}"); + println!("Partitioning Time (per frame): {:.3?}", part_duration); + println!("Regular Line Wires: {}", p.regular.len()); + println!("GPU Circle/Arc Instances: {}", p.circle_instances.len()); + println!("GPU Ellipse Instances: {}", p.ellipse_instances.len()); + println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb); + println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb); + println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 }); + println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration); + println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); + println!("============================================================================\n"); + } } From 8f2b2586ace138612a5c2b938f9e3b2b28f4139f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 7 Sep 2026 18:58:27 +0300 Subject: [PATCH 18/71] Update paper size coverage --- src/io/paper_sizes.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs index 9eaa1200d..916a961e3 100644 --- a/src/io/paper_sizes.rs +++ b/src/io/paper_sizes.rs @@ -1,4 +1,4 @@ -//! ISO paper sizes and sheet orientation for window plotting. +//! Standard paper sizes and sheet orientation for window plotting. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum PaperSize { @@ -106,10 +106,13 @@ mod tests { use super::*; #[test] - fn iso_dimensions_and_orientation() { + fn paper_dimensions_and_orientation() { assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0)); assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0)); - assert_eq!(PaperSize::ALL.len(), 5); + assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4)); + assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6)); + assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8)); + assert_eq!(PaperSize::ALL.len(), 8); assert_eq!(PaperSize::A3.label(), "A3"); // Portrait keeps (w,h); landscape swaps. assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0)); From d988a09987ef8062e9db9cc979337dd60e690360 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 7 Sep 2026 17:10:01 +0100 Subject: [PATCH 19/71] feat(render): split mixed polyline bulge arcs into GPU analytical curves and tag benchmark --- src/scene/convert/tessellate.rs | 287 ++++++++++++++++++++++++++++---- src/scene/mod.rs | 39 +++++ 2 files changed, 291 insertions(+), 35 deletions(-) diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d933002e1..31762e586 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -19,7 +19,7 @@ use acadrust::{CadDocument, EntityType, Handle}; use glam::Vec3; use crate::scene::convert::acad_to_render::{convert, RenderObject}; -use crate::scene::model::wire_model::{SnapHint, WireModel}; +use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; /// Split an f64 offset-relative coordinate into the double-single (high, low) /// f32 pair the renderer consumes. `high + low ≈ value` to ~f64 precision; the @@ -202,6 +202,150 @@ pub(crate) fn points_to_ds( (high, low) } +fn split_mixed_polyline( + tangent_geoms: &[TangentGeom], + key_vertices: &[[f64; 3]], + name: &str, + color: [f32; 4], + selected: bool, + pattern_length: f32, + pattern: [f32; 8], + line_weight_px: f32, + snap_pts: Vec<(glam::DVec3, SnapHint)>, + point_marker: Option, + plinegen: bool, +) -> Vec { + let mut out = Vec::new(); + + // Emit each arc segment as an analytical wire (CircleGpu target) + for tg in tangent_geoms { + if matches!(tg, TangentGeom::Arc { .. }) { + out.push(WireModel { + bg_adapt: None, + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.to_string(), + points: Vec::new(), + points_low: Vec::new(), + color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts: Vec::new(), + tangent_geoms: vec![tg.clone()], + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + } + + // Collect straight lines into a line wire + let mut straight_pts: Vec<[f64; 3]> = Vec::new(); + let mut straight_tangents: Vec = Vec::new(); + let mut last_end: Option<[f64; 3]> = None; + + for (i, tg) in tangent_geoms.iter().enumerate() { + if let TangentGeom::Line { p1, p2 } = tg { + straight_tangents.push(tg.clone()); + let (p_start, p_end) = if i + 1 < key_vertices.len() { + (key_vertices[i], key_vertices[i + 1]) + } else { + ( + [p1[0] as f64, p1[1] as f64, p1[2] as f64], + [p2[0] as f64, p2[1] as f64, p2[2] as f64], + ) + }; + if !plinegen { + if !straight_pts.is_empty() { + straight_pts.push([f64::NAN; 3]); + } + straight_pts.push(p_start); + straight_pts.push(p_end); + } else { + if let Some(prev) = last_end { + if (prev[0] - p_start[0]).abs() < 1e-7 + && (prev[1] - p_start[1]).abs() < 1e-7 + && (prev[2] - p_start[2]).abs() < 1e-7 + { + straight_pts.push(p_end); + } else { + straight_pts.push([f64::NAN; 3]); + straight_pts.push(p_start); + straight_pts.push(p_end); + } + } else { + straight_pts.push(p_start); + straight_pts.push(p_end); + } + last_end = Some(p_end); + } + } else { + last_end = None; + } + } + + let (line_pts, line_pts_low) = points_to_ds(straight_pts); + if !line_pts.is_empty() { + out.push(WireModel { + bg_adapt: None, + point_marker, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.to_string(), + points: line_pts, + points_low: line_pts_low, + color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts, + tangent_geoms: straight_tangents, + aci: 0, + key_vertices: key_vertices.to_vec(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } else if let Some(first_arc) = out.first_mut() { + first_arc.snap_pts = snap_pts; + first_arc.key_vertices = key_vertices.to_vec(); + } + + out +} + fn point_cloud_wires( document: &CadDocument, handle: Handle, @@ -1569,40 +1713,71 @@ pub fn tessellate( }; let point_marker = crate::entities::point::relative_marker_spec(entity, document); - out.push(WireModel { - bg_adapt: None, - point_marker, - taper_widths: Vec::new(), - pattern_stations: Vec::new(), - world_width: polyline_band_width(entity, document.header.fill_mode), - depth_override: None, - display_visible: true, - plot_visible: true, - fill_is_3d, - fill_is_2d_solid: false, - render_instance: None, - pick_tris, - pick_tris_low, - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: name.clone(), - points: local_pts, - points_low: local_pts_low, - color: edge_color, - selected, - pattern_length: edge_pattern_length, - pattern: edge_pattern, - line_weight_px, - snap_pts: snap, - tangent_geoms: tangents, - aci: 0, - key_vertices: keys, - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }); + + let has_arc = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Arc { .. })); + let has_line = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Line { .. })); + let is_thin_polyline = !is_thick_extrusion + && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 + && fill_tris.is_empty() + && pick_tris.is_empty() + && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); + + if has_arc && has_line && is_thin_polyline { + out.extend(split_mixed_polyline( + &te.tangent_geoms, + &keys, + &name, + edge_color, + selected, + edge_pattern_length, + edge_pattern, + line_weight_px, + snap, + point_marker, + true, + )); + } else { + out.push(WireModel { + bg_adapt: None, + point_marker, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: polyline_band_width(entity, document.header.fill_mode), + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d, + fill_is_2d_solid: false, + render_instance: None, + pick_tris, + pick_tris_low, + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.clone(), + points: local_pts, + points_low: local_pts_low, + color: edge_color, + selected, + pattern_length: edge_pattern_length, + pattern: edge_pattern, + line_weight_px, + snap_pts: snap, + tangent_geoms: tangents, + aci: 0, + key_vertices: keys, + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } } if !fill_tris.is_empty() { @@ -1758,6 +1933,48 @@ pub fn tessellate( // A wide polyline with PLINEGEN=0 arrives here: same shader-band // treatment as the Contour arm, restarting the dash per segment. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); + let is_thick_extrusion = matches!( + entity, + EntityType::LwPolyline(p) if p.thickness.abs() > 1e-10 + ) || matches!( + entity, + EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10 + ); + let is_thin_polyline = !is_thick_extrusion + && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 + && pick_tris.is_empty() + && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); + let has_arc = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Arc { .. })); + let has_line = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Line { .. })); + + if has_arc && has_line && is_thin_polyline { + let edge_color = if is_thick_extrusion { + [0.0, 0.0, 0.0, 1.0] + } else { + color + }; + let point_marker = + crate::entities::point::relative_marker_spec(entity, document); + return split_mixed_polyline( + &te.tangent_geoms, + &key_vertices, + &name, + edge_color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts, + point_marker, + false, + ); + } return vec![WireModel { bg_adapt: None, point_marker: None, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 278a0f17d..07d219156 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -11400,6 +11400,45 @@ mod layout_cache_tests { } #[test] + fn mixed_bulge_polyline_splits_into_analytical_arcs_and_straight_lines() { + use acadrust::entities::{LwPolyline, LwVertex}; + use acadrust::types::Vector2; + + let mut scene = Scene::new(); + let mut pline = LwPolyline::new(); + // Rounded slot: 2 straight lines and 2 semicircular ends (bulge = 1.0) + // Vertex 0: (0, 0), bulge 1.0 (arc from 0,0 to 0,10) + // Vertex 1: (0, 10), bulge 0.0 (straight from 0,10 to 50,10) + // Vertex 2: (50, 10), bulge 1.0 (arc from 50,10 to 50,0) + // Vertex 3: (50, 0), bulge 0.0 (straight from 50,0 to 0,0) + let v0 = LwVertex::with_bulge(Vector2::new(0.0, 0.0), 1.0); + let v1 = LwVertex::new(Vector2::new(0.0, 10.0)); + let v2 = LwVertex::with_bulge(Vector2::new(50.0, 10.0), 1.0); + let v3 = LwVertex::new(Vector2::new(50.0, 0.0)); + pline.vertices = vec![v0, v1, v2, v3]; + pline.is_closed = true; + + let _ = scene.add_entity(EntityType::LwPolyline(pline)); + + let camera = Camera::default(); + let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + assert_eq!( + partitioned.circle_instances.len(), + 2, + "Mixed polyline should have its 2 bulge arcs routed to analytical CircleGpu" + ); + assert_eq!( + partitioned.regular.len(), + 1, + "Mixed polyline straight lines should form 1 regular wire" + ); + } + + #[test] + #[ignore = "benchmark"] fn bench_analytical_rendering() { use std::time::Instant; use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, LwPolyline, LwVertex}; From d35a93c6e8d83daf54c43d4dae54ba79578949a6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 7 Sep 2026 19:34:31 +0300 Subject: [PATCH 20/71] Avoid reserved MCP session argument --- docs/automation/README.md | 10 +++++----- docs/automation/mcp_eval.py | 12 ++++++------ docs/automation/mcp_smoke.py | 4 ++-- src/mcp.rs | 29 +++++++++++++++++++---------- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/docs/automation/README.md b/docs/automation/README.md index d812c6244..c1e200e3b 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -17,11 +17,11 @@ The server provides four tools: - `ocs_execute` performs one operation or a sequential batch against the real editor. - `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. -The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: +The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "request": { "op": "undo", "request_id": "undo-1", @@ -37,7 +37,7 @@ A complete command can be sent with `run`. Prompt answers are separated by space ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "request": { "op": "run", "request_id": "polyline-1", @@ -52,7 +52,7 @@ Use `batch` when the steps are already known. OCS supplies each step with the st ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "response_detail": "changed_entities", "request": { "op": "batch", @@ -71,7 +71,7 @@ Execute responses use `response_detail: "compact"` by default and return only th ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "op": "query", "parameters": {"intersections": ["2A", "31"]} } diff --git a/docs/automation/mcp_eval.py b/docs/automation/mcp_eval.py index 956633c4f..db904bbb9 100644 --- a/docs/automation/mcp_eval.py +++ b/docs/automation/mcp_eval.py @@ -98,14 +98,14 @@ def main() -> None: if active.get("start"): client.tool( "ocs_execute", - {"session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, + {"ocs_session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, ) base = 1_000_000 + int(time.time()) % 100_000 draw = client.tool( "ocs_execute", { - "session_id": session, + "ocs_session_id": session, "response_detail": "changed_entities", "wait_seconds": 0, "request": { @@ -129,19 +129,19 @@ def main() -> None: crossings = client.tool( "ocs_read", - {"session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, + {"ocs_session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, ) assert crossings["count"] == 1 and crossings["intersections"][0]["point"] == [base, 0.0] nearest = client.tool( "ocs_read", - {"session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, + {"ocs_session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, ) assert nearest["entities"][0]["handle"] == circle_handles[0] measured = client.tool( "ocs_read", - {"session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, + {"ocs_session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, ) assert abs(measured["measurements"][0]["curve"]["area"] - 12.566370614359172) < 1e-9 succeeded = True @@ -151,7 +151,7 @@ def main() -> None: client.tool( "ocs_execute", { - "session_id": session, + "ocs_session_id": session, "request": { "op": "batch", "request_id": f"eval-clean-{base}", diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index e28fb6867..f523b21d0 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -110,7 +110,7 @@ def modern(server: Path) -> None: "params": { "name": "ocs_execute", "arguments": { - "session_id": "missing", + "ocs_session_id": "missing", "request": {"op": "run", "request_id": "run-1"}, }, "_meta": meta, @@ -126,7 +126,7 @@ def modern(server: Path) -> None: "method": "tools/call", "params": { "name": "ocs_execute", - "arguments": {"session_id": "missing", "request": {"op": "undo"}}, + "arguments": {"ocs_session_id": "missing", "request": {"op": "undo"}}, "_meta": meta, }, }) diff --git a/src/mcp.rs b/src/mcp.rs index 887528b62..0435147a2 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Preserve document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -756,7 +756,7 @@ fn call_tool( Ok(Value::Array(sessions(launch)?)) } "ocs_read" => { - let session_id = required_string(arguments, "session_id")?; + let session_id = required_string(arguments, "ocs_session_id")?; let op = arguments["op"].as_str().unwrap_or("state"); if !READ_OPS.contains(&op) { return Err("Use ocs_execute for mutations".into()); @@ -769,7 +769,7 @@ fn call_tool( client(clients, session_id)?.request(Value::Object(request), 30.0) } "ocs_execute" => { - let session_id = required_string(arguments, "session_id")?; + let session_id = required_string(arguments, "ocs_session_id")?; let request = arguments["request"] .as_object() .cloned() @@ -795,7 +795,7 @@ fn call_tool( shape_execute_response(response, detail, gui) } "ocs_capture" => { - let session_id = required_string(arguments, "session_id")?; + let session_id = required_string(arguments, "ocs_session_id")?; let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); let scope = arguments["scope"].as_str().unwrap_or("viewport"); let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600); @@ -944,21 +944,21 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. accepted, running and waiting_input are not completion.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["session_id","request"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false}, "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_capture", "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["ocs_session_id"],"additionalProperties":false}, "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) @@ -1271,6 +1271,15 @@ mod tests { ["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"] ); assert_eq!(tools[0]["annotations"]["readOnlyHint"], false); + for tool in [&tools[1], &tools[2], &tools[3]] { + assert!(tool["inputSchema"]["properties"] + .get("session_id") + .is_none()); + assert!(tool["inputSchema"]["required"] + .as_array() + .unwrap() + .contains(&json!("ocs_session_id"))); + } assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["required"], json!(["op", "request_id"]) @@ -1368,7 +1377,7 @@ mod tests { fn read_tool_rejects_mutations_before_connecting() { let mut clients = HashMap::new(); let called = handle_message( - json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"ocs_session_id":"missing","op":"save"}}}), &mut clients, &mut TaskStore::default(), ) @@ -1397,7 +1406,7 @@ mod tests { fn execute_requires_a_visible_request_id() { let mut clients = HashMap::new(); let called = handle_message( - json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"undo"}}}}), + json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"undo"}}}}), &mut clients, &mut TaskStore::default(), ) @@ -1415,7 +1424,7 @@ mod tests { fn execute_errors_explain_missing_operation_fields() { let mut clients = HashMap::new(); let called = handle_message( - json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), + json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), &mut clients, &mut TaskStore::default(), ) From ce68999cf9459161da8c05499766d484629d226f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 7 Sep 2026 19:47:38 +0300 Subject: [PATCH 21/71] Set the default Cargo binary --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 2051beb5f..8ce6a5569 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "OpenCADStudio" version = "2026.36.0" edition = "2021" build = "build.rs" +default-run = "OpenCADStudio" [workspace] members = [ From 67163de15dfcce6dace5ceb0349039e4df4c07a4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 7 Sep 2026 20:27:42 +0300 Subject: [PATCH 22/71] Update kernel for reliable wall opening cuts --- 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 37a3f6990..326bb7474 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=81ad6019564ba4b3785affdc99adbb6600b2d2ea#81ad6019564ba4b3785affdc99adbb6600b2d2ea" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=4486076cb003a5909e3383900c596f92ef599192#4486076cb003a5909e3383900c596f92ef599192" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 8ce6a5569..21a3ba4a8 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 = "81ad6019564ba4b3785affdc99adbb6600b2d2ea", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", 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 1f877f22d..a5476d897 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 = "81ad6019564ba4b3785affdc99adbb6600b2d2ea", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] From b7c2ce2e137182a6fc557e29cac65604495df653 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Mon, 7 Sep 2026 18:18:36 -0300 Subject: [PATCH 23/71] fix(hatch): refresh associative hatch during geometry edits --- src/scene/boundary.rs | 94 ++++++++++++++++++++++++++++++++++++++----- src/scene/modify.rs | 9 +++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 020ba2e7e..c04691960 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -84,6 +84,38 @@ fn entity_curves_on_plane( } } +fn entity_boundary_source_on_plane( + entity: &EntityType, + plane: WorkingPlane, + tolerance: f64, +) -> Option { + let curves = entity_curves_on_plane(entity, plane, tolerance); + + if curves.is_empty() { + return None; + } + + let segments: Vec = curves + .iter() + .flat_map(|curve| { + curve + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2) + .filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + + (start.iter().chain(&end).all(|value| value.is_finite()) + && start != end) + .then_some(Line { start, end }) + }) + .collect::>() + }) + .collect(); + + (!segments.is_empty()).then_some(BoundarySource { segments, curves }) +} + fn hatch_path_seed(path: &acadrust::entities::BoundaryPath) -> Option<[f64; 2]> { let mut ring = Vec::new(); for edge in &path.edges { @@ -731,10 +763,22 @@ impl Scene { .boundary_handles .iter() .filter_map(|source| { - all_sources - .get(source) - .cloned() - .map(|geometry| (*source, geometry)) + // Associative boundaries are explicit document references. + // During a grip drag their display wire can be preview-hidden, + // so prefer the live entity geometry over the resident wire cache. + let geometry = self + .document + .get_entity(*source) + .and_then(|entity| { + entity_boundary_source_on_plane( + entity, + plane, + WELD_TOLERANCE, + ) + }) + .or_else(|| all_sources.get(source).cloned()); + + geometry.map(|geometry| (*source, geometry)) }) .collect(); let segments: Vec<_> = sources @@ -817,17 +861,47 @@ impl Scene { .extend(segments); } for (&handle, source) in &mut sources { - source.curves = self + let curves = self .document .get_entity(handle) .map(|entity| entity_curves_on_plane(entity, plane, tolerance)) .unwrap_or_default(); - source.curves.retain(|curve| { - source.segments.iter().any(|segment| { - distance_to(curve, segment.start).max(distance_to(curve, segment.end)) - <= tolerance * 4.0 + + // Associative hatch regeneration must use the entity's live geometry, + // not a potentially one-frame-old display wire. Grip edits and STRETCH + // mutate the document before the resident wire cache catches up. + let live_segments: Vec = curves + .iter() + .flat_map(|curve| { + curve + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2) + .filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + + (start.iter().chain(&end).all(|value| value.is_finite()) + && start != end) + .then_some(Line { start, end }) + }) + .collect::>() }) - }); + .collect(); + + if !live_segments.is_empty() { + source.segments = live_segments; + source.curves = curves; + } else { + // Fallback for entity types that do not expose usable native curves: + // retain the existing display-wire geometry. + source.curves = curves; + source.curves.retain(|curve| { + source.segments.iter().any(|segment| { + distance_to(curve, segment.start).max(distance_to(curve, segment.end)) + <= tolerance * 4.0 + }) + }); + } } sources } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index a37dfc39c..f84605425 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1387,5 +1387,14 @@ impl Scene { self.images.insert(handle, model); } } + // A grip edit can move geometry used as the boundary of an associative hatch. + // The source entity has already been mutated above, so rebuild any dependent + // hatch from the live document geometry immediately. + let source_change = [(handle, ChangeKind::Modified)]; + let hatch_changes = self.refresh_associative_hatches(&source_change); + + if !hatch_changes.is_empty() { + self.bump_entities(&hatch_changes); + } } } From cbfdcd9ecd23466e5588c6839b620af9b02fe34a Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Mon, 7 Sep 2026 18:48:18 -0300 Subject: [PATCH 24/71] fix(fillet): support parallel line fillets --- src/modules/draw/modify/fillet.rs | 133 ++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 6 deletions(-) diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index 450c8b48b..0df71082d 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -77,7 +77,122 @@ fn project_click(click: [f64; 2], p1: [f64; 2], unit: [f64; 2]) -> f64 { } // ── Fillet ───────────────────────────────────────────────────────────────── +/// Fillet two parallel lines with a semicircle tangent to both. +/// +/// The current FILLET radius is intentionally ignored for parallel lines: +/// the effective radius is half the perpendicular distance between them. +/// +/// The endpoint of the first line nearest its pick determines where the +/// semicircle is placed. The second line is trimmed or extended to the +/// corresponding perpendicular tangent point. +fn fillet_parallel_lines( + l1: &LineEnt, + click1: [f64; 2], + l2: &LineEnt, + click2: [f64; 2], +) -> Option<(EntityType, EntityType, Option)> { + let (p1, p2, u1, len1) = line_geom(l1); + let (p3, _p4, u2, len2) = line_geom(l2); + if len1 <= 1.0e-12 || len2 <= 1.0e-12 { + return None; + } + + // This helper is only for parallel / anti-parallel lines. + let cross = u1[0] * u2[1] - u1[1] * u2[0]; + if cross.abs() > 1.0e-6 { + return None; + } + + // The pick on the first line determines which end receives the round. + let dist_sq = |a: [f64; 2], b: [f64; 2]| { + let dx = a[0] - b[0]; + let dy = a[1] - b[1]; + dx * dx + dy * dy + }; + + let (tangent1, other1) = if dist_sq(click1, p1) <= dist_sq(click1, p2) { + (p1, p2) + } else { + (p2, p1) + }; + + // Unit normal to line 1. Project tangent1 perpendicularly onto line 2. + let normal = [-u1[1], u1[0]]; + let separation = + (p3[0] - tangent1[0]) * normal[0] + (p3[1] - tangent1[1]) * normal[1]; + + if separation.abs() <= 1.0e-9 { + // Coincident lines do not define a useful semicircle. + return None; + } + + let tangent2 = [ + tangent1[0] + normal[0] * separation, + tangent1[1] + normal[1] * separation, + ]; + + let centre = [ + (tangent1[0] + tangent2[0]) * 0.5, + (tangent1[1] + tangent2[1]) * 0.5, + ]; + + let radius = separation.abs() * 0.5; + + // Keep the existing part of each selected line and trim/extend the picked + // end to its tangent point. + let new_l1 = trim_line_to_point(l1, tangent1, click1)?; + let new_l2 = trim_line_to_point(l2, tangent2, click2)?; + + // The semicircle must bulge away from the retained portion of line 1. + // `other1 - tangent1` points back into the line, so negate it. + let keep = [ + other1[0] - tangent1[0], + other1[1] - tangent1[1], + ]; + let keep_len = (keep[0] * keep[0] + keep[1] * keep[1]).sqrt(); + + if keep_len <= 1.0e-12 { + return None; + } + + let bulge_dir = [-keep[0] / keep_len, -keep[1] / keep_len]; + + let a1 = norm_angle( + (tangent1[1] - centre[1]).atan2(tangent1[0] - centre[0]), + ); + let a2 = norm_angle( + (tangent2[1] - centre[1]).atan2(tangent2[0] - centre[0]), + ); + + // Between two antipodal points there are two possible semicircles. + // Determine which CCW orientation bulges toward the selected end. + let midpoint_angle = a1 + std::f64::consts::FRAC_PI_2; + let midpoint_dir = [midpoint_angle.cos(), midpoint_angle.sin()]; + + let candidate_matches = + midpoint_dir[0] * bulge_dir[0] + midpoint_dir[1] * bulge_dir[1] >= 0.0; + + let (start_angle, end_angle) = if candidate_matches { + (a1, a2) + } else { + (a2, a1) + }; + + let mut arc = ArcEnt::new(); + arc.common = l1.common.clone(); + arc.common.handle = Handle::NULL; + arc.center = Vector3::new(centre[0], centre[1], l1.start.z); + arc.radius = radius; + arc.start_angle = start_angle; + arc.end_angle = end_angle; + + Some(( + EntityType::Line(new_l1), + EntityType::Line(new_l2), + Some(EntityType::Arc(arc)), + )) +} /// Compute fillet: trim l1/l2 and insert a tangent arc of `radius`. /// Returns (trimmed_l1, trimmed_l2, fillet_arc). fn compute_fillet( @@ -90,8 +205,18 @@ fn compute_fillet( let (p1, p2, u1, _len1) = line_geom(l1); let (p3, p4, u2, _len2) = line_geom(l2); - // Intersection of infinite lines - let (t_p, _u_p) = ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?; + // Parallel lines are a special FILLET case: there is no intersection. + // Build the tangent semicircle directly, temporarily ignoring the + // configured radius. + let cross = u1[0] * u2[1] - u1[1] * u2[0]; + + if cross.abs() <= 1.0e-6 { + return fillet_parallel_lines(l1, click1, l2, click2); + } + + // Intersection of infinite non-parallel lines. + let (t_p, _u_p) = + ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?; // Intersection point let px = p1[0] + t_p * u1[0]; @@ -127,10 +252,6 @@ fn compute_fillet( let cos_a = (dir1[0] * dir2[0] + dir1[1] * dir2[1]).clamp(-1.0, 1.0); let angle = cos_a.acos(); - // Lines are parallel / anti-parallel - if angle < 1e-6 || (angle - std::f64::consts::PI).abs() < 1e-6 { - return None; - } let z = l1.start.z; From 4e46deee492c8517efe3491431cb3495ac920b26 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Mon, 7 Sep 2026 19:46:43 -0300 Subject: [PATCH 25/71] feat(fillet): add polyline fillet option --- src/modules/draw/modify/fillet.rs | 268 ++++++++++++++++++++++++++++-- 1 file changed, 256 insertions(+), 12 deletions(-) diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index 0df71082d..e23f3635a 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -248,10 +248,6 @@ fn compute_fillet( [-u2[0], -u2[1]] }; - // Angle between the two keep-directions - let cos_a = (dir1[0] * dir2[0] + dir1[1] * dir2[1]).clamp(-1.0, 1.0); - let angle = cos_a.acos(); - let z = l1.start.z; @@ -458,7 +454,194 @@ fn compute_bulge(t1: [f64; 2], t2: [f64; 2], arc_center: [f64; 2]) -> f64 { -(span_cw / 4.0).tan() // CW arc } } +#[derive(Clone, Copy)] +struct PolylineCornerFillet { + incoming: [f64; 2], + outgoing: [f64; 2], + bulge: f64, +} +fn segment_parameter(point: [f64; 2], start: [f64; 2], end: [f64; 2]) -> Option { + let dx = end[0] - start[0]; + let dy = end[1] - start[1]; + let len2 = dx * dx + dy * dy; + + if len2 <= 1.0e-20 { + return None; + } + + Some( + ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / len2, + ) +} + +/// Apply the current FILLET radius to every eligible corner of a 2D polyline. +/// +/// Initial scope deliberately handles straight-segment corners only. Corners +/// adjacent to an existing bulge arc are preserved unchanged. +fn fillet_entire_lwpolyline(poly: &LwPolyline, radius: f64) -> Option { + let n = poly.vertices.len(); + + if n < 3 || radius <= 1.0e-9 { + return None; + } + + let mut corners: Vec> = vec![None; n]; + + let first_corner = if poly.is_closed { 0 } else { 1 }; + let end_corner = if poly.is_closed { + n + } else { + n.saturating_sub(1) + }; + + for i in first_corner..end_corner { + let prev = if i == 0 { n - 1 } else { i - 1 }; + + // A bulge belongs to the segment starting at that vertex. + // For now only fillet corners where both adjacent segments are straight. + if poly.vertices[prev].bulge.abs() >= 1.0e-9 + || poly.vertices[i].bulge.abs() >= 1.0e-9 + { + continue; + } + + let incoming = lwpoly_seg_as_line(poly, prev); + let outgoing = lwpoly_seg_as_line(poly, i); + + // Pick points halfway along each segment, away from the corner. + // This tells the existing line-line FILLET code which side to keep. + let click_in = [ + (incoming.start.x + incoming.end.x) * 0.5, + (incoming.start.y + incoming.end.y) * 0.5, + ]; + let click_out = [ + (outgoing.start.x + outgoing.end.x) * 0.5, + (outgoing.start.y + outgoing.end.y) * 0.5, + ]; + + let Some((trimmed_in, trimmed_out, maybe_arc)) = + compute_fillet(&incoming, click_in, &outgoing, click_out, radius) + else { + continue; + }; + + let ( + EntityType::Line(trimmed_in), + EntityType::Line(trimmed_out), + Some(EntityType::Arc(arc)), + ) = (trimmed_in, trimmed_out, maybe_arc) + else { + continue; + }; + + let tangent_in = [trimmed_in.end.x, trimmed_in.end.y]; + let tangent_out = [trimmed_out.start.x, trimmed_out.start.y]; + + let in_start = [incoming.start.x, incoming.start.y]; + let in_end = [incoming.end.x, incoming.end.y]; + let out_start = [outgoing.start.x, outgoing.start.y]; + let out_end = [outgoing.end.x, outgoing.end.y]; + + let Some(t_in) = segment_parameter(tangent_in, in_start, in_end) else { + continue; + }; + let Some(t_out) = segment_parameter(tangent_out, out_start, out_end) else { + continue; + }; + + // Whole-polyline FILLET should not extend past neighbouring vertices. + // If this radius cannot fit at this corner, leave that corner unchanged. + if !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_in) + || !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_out) + { + continue; + } + + corners[i] = Some(PolylineCornerFillet { + incoming: tangent_in, + outgoing: tangent_out, + bulge: compute_bulge( + tangent_in, + tangent_out, + [arc.center.x, arc.center.y], + ), + }); + } + + if corners.iter().all(Option::is_none) { + return None; + } + + // Adjacent fillets must not consume more than the shared segment. + // Reject the operation rather than producing an inverted/overlapping edge. + let segment_count = if poly.is_closed { n } else { n - 1 }; + + for i in 0..segment_count { + let next = (i + 1) % n; + + let start = corners[i] + .map(|corner| corner.outgoing) + .unwrap_or([ + poly.vertices[i].location.x, + poly.vertices[i].location.y, + ]); + + let end = corners[next] + .map(|corner| corner.incoming) + .unwrap_or([ + poly.vertices[next].location.x, + poly.vertices[next].location.y, + ]); + + let original_start = [ + poly.vertices[i].location.x, + poly.vertices[i].location.y, + ]; + let original_end = [ + poly.vertices[next].location.x, + poly.vertices[next].location.y, + ]; + + let Some(start_t) = segment_parameter(start, original_start, original_end) else { + continue; + }; + let Some(end_t) = segment_parameter(end, original_start, original_end) else { + continue; + }; + + if start_t > end_t + 1.0e-9 { + return None; + } + } + + let mut result = poly.clone(); + result.common.handle = Handle::NULL; + result.vertices.clear(); + + for (i, original) in poly.vertices.iter().enumerate() { + if let Some(corner) = corners[i] { + let mut tangent_in = original.clone(); + tangent_in.location.x = corner.incoming[0]; + tangent_in.location.y = corner.incoming[1]; + tangent_in.bulge = corner.bulge; + + let mut tangent_out = original.clone(); + tangent_out.location.x = corner.outgoing[0]; + tangent_out.location.y = corner.outgoing[1]; + + // This corner is only eligible when its outgoing segment is straight. + tangent_out.bulge = 0.0; + + result.vertices.push(tangent_in); + result.vertices.push(tangent_out); + } else { + result.vertices.push(original.clone()); + } + } + + Some(result) +} /// Rebuild a LwPolyline, replacing the corner vertex at `corner_idx` with two /// new vertices T1 (start of fillet arc) and T2 (end of fillet arc). /// `bulge` encodes the direction and span of the arc (T1 → T2). @@ -1112,6 +1295,7 @@ fn compute_chamfer( enum FilletStep { First, + Polyline, WaitingForRadius, Second { h1: Handle, @@ -1193,7 +1377,12 @@ impl CadCommand for FilletCommand { fn prompt(&self) -> String { match &self.step { FilletStep::First => crate::tf!( - "FILLET Select first object (Line/Arc/LwPolyline) [R={:.4}]:", + "FILLET Select first object or [Polyline/Radius] [R={:.4}]:", + self.radius + ) + .into_owned(), + FilletStep::Polyline => crate::tf!( + "FILLET Select 2D polyline [R={:.4}]:", self.radius ) .into_owned(), @@ -1212,11 +1401,16 @@ impl CadCommand for FilletCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + match self.step { - // Offer the radius keyword while selecting objects; the "R" token is - // already handled by on_text_input. - FilletStep::First | FilletStep::Second { .. } => vec![CmdOption::new("Radius", "R")], - FilletStep::WaitingForRadius => vec![], + FilletStep::First => vec![ + CmdOption::new("Polyline", "P"), + CmdOption::new("Radius", "R"), + ], + FilletStep::Second { .. } => { + vec![CmdOption::new("Radius", "R")] + } + FilletStep::Polyline | FilletStep::WaitingForRadius => vec![], } } @@ -1255,6 +1449,10 @@ impl CadCommand for FilletCommand { FilletStep::First | FilletStep::Second { .. } => { let t = text.trim(); let upper = t.to_uppercase(); + if matches!(self.step, FilletStep::First) && upper == "P" { + self.step = FilletStep::Polyline; + return Some(CmdResult::NeedPoint); + } // "R" alone → enter sub-step to collect radius if upper == "R" { self.enter_radius_substep(); @@ -1277,6 +1475,7 @@ impl CadCommand for FilletCommand { } None } + FilletStep::Polyline => None, } } @@ -1292,6 +1491,28 @@ impl CadCommand for FilletCommand { match &self.step { FilletStep::WaitingForRadius => return CmdResult::NeedPoint, + FilletStep::Polyline => { + let poly = self + .entity_index + .get(&self.all_entities, handle) + .and_then(|entity| match entity { + EntityType::LwPolyline(poly) => Some(poly.clone()), + _ => None, + }); + + let Some(poly) = poly else { + return CmdResult::NeedPoint; + }; + + let Some(result) = fillet_entire_lwpolyline(&poly, self.radius) else { + return CmdResult::NeedPoint; + }; + + self.continue_after_fillet(vec![( + handle, + vec![EntityType::LwPolyline(result)], + )]) + } FilletStep::First => { let e1 = self .entity_index @@ -1366,6 +1587,28 @@ impl CadCommand for FilletCommand { match &self.step { FilletStep::WaitingForRadius => vec![], + FilletStep::Polyline => { + let preview = self + .entity_index + .get(&self.all_entities, handle) + .and_then(|entity| match entity { + EntityType::LwPolyline(poly) => { + fillet_entire_lwpolyline(poly, self.radius) + } + _ => None, + }); + + preview + .map(|poly| { + vec![WireModel::solid( + "fillet_polyline_preview".into(), + lwpoly_pts(&poly), + WireModel::CYAN, + false, + )] + }) + .unwrap_or_default() + } FilletStep::First => { let pts = self .entity_index @@ -1636,12 +1879,13 @@ impl CadCommand for ChamferCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + match self.step { - // Offer the distance keyword while selecting objects; the "D" token - // is already handled by on_text_input. ChamferStep::First | ChamferStep::Second { .. } - | ChamferStep::SecondPoly { .. } => vec![CmdOption::new(t!("Distance").as_ref(), "D")], + | ChamferStep::SecondPoly { .. } => { + vec![CmdOption::new(t!("Distance").as_ref(), "D")] + } ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![], } } From 8497abecfd2625461738f87e821fedcb0940ab03 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 8 Sep 2026 08:34:58 +0200 Subject: [PATCH 26/71] 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 27/71] 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 28/71] 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 29/71] 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 30/71] 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 31/71] 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 32/71] 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 33/71] 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 34/71] 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 35/71] 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 36/71] 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 37/71] 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 38/71] 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 39/71] 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 40/71] 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 41/71] 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 42/71] 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 43/71] 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 44/71] 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 45/71] 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 46/71] 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 47/71] 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 48/71] 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 49/71] 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 50/71] 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 51/71] 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 52/71] 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 53/71] 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 54/71] 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 55/71] 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 56/71] 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 57/71] 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 58/71] 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 59/71] 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 18517a20a3e4aacb1d799841eb5723910357cb38 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:23:06 +0300 Subject: [PATCH 60/71] perf: let the first edit after opening a file patch instead of rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first drawn point costs 946 ms against 17-39 ms for every one after it, and `build=587.67ms` is most of that — a full wire assembly where the following points patch in 1.2 ms. `try_resident_patch` refuses any resident set whose `layout` is `None`, and `install_prepared_open_geometry` inserted the loader's set with exactly that. So the set the file open hands over could never be patched, and the first edit rebuilt all 236 954 wires to add one. The layout is a pure function of the wire order, and the loader already built it for those very wires — `resident_wires_for` stores it in the temporary scene's resident set. It now travels in `PreparedOpenGeometry` like the tessellation memo and the draw-order map, taken by `Arc::ptr_eq` identity so it cannot be paired with a different assembly. `try_resident_patch` also reports why it declines. Each refusal costs a full assembly, and until now the only one that said anything was the shared `Arc`; `no-layout`, `no-entry` and `journal-too-old` were silent. **Not verified.** The headless harness opens through the synchronous path, so `install_prepared_open_geometry` never runs there, and `ZOOM E` does not reach `try_resident_patch` at all — no skip line is emitted for it. This needs a GUI run on the reproducer: the first drawn point should drop well below 946 ms, and if it does not, `resident-patch-skip reason=` now says what is still refusing. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 52 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 187b0d7a5..0cdbcde7b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -411,7 +411,8 @@ struct ResidentWireSet { layout: Option, } -struct ResidentWireLayout { +#[derive(Clone)] +pub(crate) struct ResidentWireLayout { /// Entity handles in final submission order. A temporarily hidden entity /// remains here with no range so grip commit can restore it in place. order: Vec, @@ -435,6 +436,12 @@ pub struct PreparedOpenGeometry { /// Loader tessellations and their guard; a state mismatch clears the memo. pub tess_memo: HashMap>>, pub tess_guard: u64, + /// The loader's submission-order layout for `wires`. + /// + /// Without it the installed set has `layout: None`, and `try_resident_patch` + /// refuses any set that has none — so the first edit after opening a file + /// could never patch and rebuilt the whole wire assembly instead. + pub(crate) resident_layout: Option, /// 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 @@ -1032,6 +1039,17 @@ pub fn prepare_open_geometry( 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(); + // The layout the loader already built for exactly these wires. Taken by + // identity so it cannot be paired with a different assembly. + let resident_layout = scene + .resident_wire_sets + .borrow_mut() + .drain() + .find_map(|(_, set)| { + Arc::ptr_eq(&set.wires, &wires) + .then_some(set.layout) + .flatten() + }); let block_defns: Vec<(u64, Arc)> = std::mem::take(&mut *scene.block_defn_cache.borrow_mut()) .into_iter() @@ -1047,6 +1065,7 @@ pub fn prepare_open_geometry( tess_guard, block_defns, draw_depths, + resident_layout, }, ) } @@ -2248,7 +2267,7 @@ impl Scene { epoch: self.geometry_epoch, gen, wires: Arc::clone(&prepared.wires), - layout: None, + layout: prepared.resident_layout, }, ); // Adopt the loader thread's tessellation memo so the first edit patches @@ -5497,19 +5516,33 @@ impl Scene { frozen_layers: Option<&HashSet>, style_viewport: Option, ) -> Option>> { - if self.viewport_style_key(style_viewport) != 0 { - return None; - } let perf = crate::perf::enabled(); + // Each early exit here costs a full wire assembly, so say which one. + let declined = |reason: &str| -> Option>> { + if perf { + crate::perf_record!("[perf] resident-patch-skip reason={reason}"); + } + None + }; + if self.viewport_style_key(style_viewport) != 0 { + return declined("viewport-style"); + } let t_patch = iced::time::Instant::now(); // The entry must exist, be stale, and be uniquely held so we can move // its wires out rather than deep-clone them. let cached_epoch = { let sets = self.resident_wire_sets.borrow(); - let entry = sets.get(&key)?; - if entry.epoch == self.geometry_epoch || entry.layout.is_none() { + let Some(entry) = sets.get(&key) else { + drop(sets); + return declined("no-entry"); + }; + if entry.epoch == self.geometry_epoch { return None; } + if entry.layout.is_none() { + drop(sets); + return declined("no-layout"); + } let strong = Arc::strong_count(&entry.wires); if strong != 1 { if perf { @@ -5519,7 +5552,9 @@ impl Scene { } entry.epoch }; - let deltas = self.replay_since(cached_epoch)?; + let Some(deltas) = self.replay_since(cached_epoch) else { + return declined("journal-too-old"); + }; // Take ownership of the cached assembly (guaranteed unique above). // `prev_gen` is the content id the GPU currently holds for this set — the @@ -10827,6 +10862,7 @@ mod journal_tests { tess_guard: 0, block_defns: Vec::new(), draw_depths: Some(handed_over), + resident_layout: None, }); assert_eq!( *scene.draw_depth_map(), From 058cd02042958a1894ddbd8a8ad5d97a156523dc Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 8 Sep 2026 15:48:48 +0100 Subject: [PATCH 61/71] 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 62/71] 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 63/71] 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 64/71] 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 65/71] 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 66/71] 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"] } From be06a756da432486d96341170f8c4be4dc654b13 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:37:35 +0300 Subject: [PATCH 67/71] Implement solid shell workflow and properties --- Cargo.lock | 4 +- Cargo.toml | 6 +- crates/ocs_doc_api/Cargo.toml | 4 +- src/app/command_driver.rs | 21 +++ src/app/commands/draw.rs | 24 +++ src/app/control/mod.rs | 2 +- src/app/model_ops.rs | 103 +++++++++++- src/app/properties.rs | 111 +++++++++---- src/command.rs | 17 ++ src/modules/model/mod.rs | 2 + src/modules/model/shell_cmd.rs | 265 +++++++++++++++++++++++++++++++ src/scene/convert/acis_kernel.rs | 5 + src/scene/model/mesh_model.rs | 163 ++++++++++++++++++- src/scene/model/solid_history.rs | 83 +++++++++- src/scene/model/solid_model.rs | 18 ++- src/scene/modify.rs | 4 +- 16 files changed, 780 insertions(+), 52 deletions(-) create mode 100644 src/modules/model/shell_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 326bb7474..002d00d86 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://git@github.com/HakanSeven12/cadcodec.git?rev=ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01#ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01" 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/HakanSeven12/cadkernel.git?rev=e0f43546929b6af0e436e710bdfc932161d483a3#e0f43546929b6af0e436e710bdfc932161d483a3" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 21a3ba4a8..9932c9371 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 = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "e0f43546929b6af0e436e710bdfc932161d483a3", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -74,7 +74,7 @@ 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://git@github.com/HakanSeven12/cadcodec.git", rev = "ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index a5476d897..a7d8b4119 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 = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "4486076cb003a5909e3383900c596f92ef599192", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "ffc58a9ee64b2ee818d7cf1e4d8b27a26becbf01", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "e0f43546929b6af0e436e710bdfc932161d483a3", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b74e15fd..b40bc3693 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2926,6 +2926,14 @@ impl OpenCADStudio { self.command_line.push_info(&prompt); } } + CmdResult::ReportError(msg) => { + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.command_line.push_error(&msg); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } CmdResult::ReportMeasurementAndDeselect(msg) => { self.tabs[i].snap_result = None; self.tabs[i].scene.deselect_all(); @@ -4115,6 +4123,19 @@ impl OpenCADStudio { return task; } + CmdResult::SolidShell { + handle, + actions, + distance, + } => { + let task = self.solid_shell(handle, &actions, distance); + 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/draw.rs b/src/app/commands/draw.rs index 3a2b4a131..927e593d6 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -971,6 +971,30 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "SHELL" | "SOLIDEDIT" => { + use crate::modules::model::shell_cmd::ShellCommand; + let selected = self.tabs[i] + .scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .collect::>(); + let target = (selected.len() == 1).then_some(selected[0]); + let new_cmd = if cmd == "SHELL" { + ShellCommand::direct(target) + } else { + ShellCommand::solid_edit(target) + }; + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + // ── Solid booleans ───────────────────────────────────────────── "UNION" => { use crate::modules::model::boolean_cmd::BoolOp; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 3f1f58b26..0e29ebfc6 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -982,7 +982,7 @@ impl OpenCADStudio { "area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs()) }) }); - out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); + out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid,"moment_of_inertia":m.metrics.moment_of_inertia,"principal_directions":m.metrics.principal_directions,"principal_moments":m.metrics.principal_moments,"product_of_inertia":m.metrics.product_of_inertia,"radii_of_gyration":m.metrics.radii_of_gyration}))})); } json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a262c0ee3..183e8fd63 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -9,7 +9,7 @@ use acadrust::{ }; use cadkernel::brep::Body; use iced::Task; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; @@ -646,6 +646,107 @@ impl super::OpenCADStudio { Task::none() } + pub(super) fn solid_shell( + &mut self, + handle: Handle, + actions: &[crate::command::ShellFaceAction], + distance: f64, + ) -> Task { + use crate::command::ShellFaceAction; + + 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(_)) + ) { + self.command_line + .push_error(crate::t!("Select a 3D solid.").as_ref()); + return Task::none(); + } + if !distance.is_finite() || distance.abs() <= f64::EPSILON { + self.command_line + .push_error(crate::t!("The shell offset distance must be nonzero.").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 solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + + let all_faces = body.face_keys().collect::>(); + let mut removed = HashSet::new(); + for action in actions { + match *action { + ShellFaceAction::Remove(point) => { + let Some(face) = solid_model::nearest_face(&body, point.to_array()) else { + self.command_line + .push_error(crate::t!("A selected face could not be resolved.").as_ref()); + return Task::none(); + }; + removed.insert(face); + } + ShellFaceAction::Add(point) => { + let Some(face) = solid_model::nearest_face(&body, point.to_array()) else { + self.command_line + .push_error(crate::t!("A selected face could not be resolved.").as_ref()); + return Task::none(); + }; + removed.remove(&face); + } + ShellFaceAction::RemoveAll => removed.extend(all_faces.iter().copied()), + ShellFaceAction::AddAll => removed.clear(), + } + } + let removed = removed.into_iter().collect::>(); + + let result = match cadkernel::brep::shell(&body, &removed, distance) { + Ok(result) => result, + Err(cadkernel::brep::ShellError::InvalidDistance) => { + self.command_line + .push_error(crate::t!("The shell offset distance must be nonzero.").as_ref()); + return Task::none(); + } + Err(cadkernel::brep::ShellError::UnsupportedSolid) => { + self.command_line.push_error( + crate::t!("SHELL supports rectangular boxes, circular cylinders, and spheres.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::ShellError::UnknownFace) => { + self.command_line + .push_error(crate::t!("A selected face does not belong to the solid.").as_ref()); + return Task::none(); + } + Err(cadkernel::brep::ShellError::NoMaterial) => { + self.command_line.push_error( + crate::t!("The offset removes all material. Use a smaller distance or add a face back.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::ShellError::Kernel(_)) => { + self.command_line.push_error( + crate::t!("The shell could not be constructed from the selected solid and distance.") + .as_ref(), + ); + return Task::none(); + } + }; + + if self.replace_solid_body(handle, result, "SHELL") { + self.command_line + .push_output(crate::t!("SHELL: 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/properties.rs b/src/app/properties.rs index ad055f125..a72daeaf4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -636,7 +636,7 @@ impl OpenCADStudio { } } - if !compact_solid && matches!( + if matches!( entity, acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Region(_) @@ -654,38 +654,44 @@ impl OpenCADStudio { { use crate::entities::common::ro_prop; let metrics = mesh.metrics; - let mut props = vec![ - ro_prop( - t!("Vertices").as_ref(), - "mesh_vertices", - metrics.vertices.to_string(), - ), - ro_prop( - t!("Triangles").as_ref(), - "mesh_triangles", - metrics.triangles.to_string(), - ), - ro_prop( - t!("Surface Area").as_ref(), - "mesh_surface_area", - format!("{:.6}", metrics.surface_area), - ), - ro_prop( + let triple = |values: [f64; 3]| { + format!("{:.6}, {:.6}, {:.6}", values[0], values[1], values[2]) + }; + let mut props = if compact_solid { + vec![ro_prop( t!("Centroid").as_ref(), "mesh_centroid", - format!( - "{:.6}, {:.6}, {:.6}", - metrics.centroid[0], - metrics.centroid[1], - metrics.centroid[2] + triple(metrics.centroid), + )] + } else { + vec![ + ro_prop( + t!("Vertices").as_ref(), + "mesh_vertices", + metrics.vertices.to_string(), ), - ), - ro_prop( - t!("Tessellation").as_ref(), - "mesh_complete", - if mesh.complete { "Complete" } else { "Partial" }, - ), - ]; + ro_prop( + t!("Triangles").as_ref(), + "mesh_triangles", + metrics.triangles.to_string(), + ), + ro_prop( + t!("Surface Area").as_ref(), + "mesh_surface_area", + format!("{:.6}", metrics.surface_area), + ), + ro_prop( + t!("Centroid").as_ref(), + "mesh_centroid", + triple(metrics.centroid), + ), + ro_prop( + t!("Tessellation").as_ref(), + "mesh_complete", + if mesh.complete { "Complete" } else { "Partial" }, + ), + ] + }; if mesh.complete && matches!( entity, @@ -694,14 +700,50 @@ impl OpenCADStudio { | acadrust::EntityType::Body(_) ) { - props.insert( - 3, + let closed_props = vec![ + ro_prop( + t!("Moment of inertia").as_ref(), + "mesh_moment_of_inertia", + triple(metrics.moment_of_inertia), + ), + ro_prop( + t!("Principal directions").as_ref(), + "mesh_principal_directions", + metrics + .principal_directions + .chunks_exact(3) + .map(|axis| format!("({:.6}, {:.6}, {:.6})", axis[0], axis[1], axis[2])) + .collect::>() + .join(", "), + ), + ro_prop( + t!("Principal moments").as_ref(), + "mesh_principal_moments", + triple(metrics.principal_moments), + ), + ro_prop( + t!("Product of inertia").as_ref(), + "mesh_product_of_inertia", + triple(metrics.product_of_inertia), + ), + ro_prop( + t!("Radii of gyration").as_ref(), + "mesh_radii_of_gyration", + triple(metrics.radii_of_gyration), + ), ro_prop( t!("Volume").as_ref(), "mesh_volume", format!("{:.6}", metrics.volume), ), - ); + ]; + if compact_solid { + props.extend(closed_props); + } else { + let volume = closed_props.last().cloned().unwrap(); + props.insert(3, volume); + props.extend(closed_props.into_iter().take(5)); + } } sections.push(crate::scene::model::object::PropSection { title: t!("Mass Properties").into_owned(), @@ -3093,7 +3135,8 @@ fn retain_compact_solid_sections( | "transparency" | "hyperlink" | "material" - ) || crate::scene::model::solid_history::is_specialized_property(property.field) + ) || property.field.starts_with("mesh_") + || crate::scene::model::solid_history::is_specialized_property(property.field) }); }); sections.retain(|section| !section.props.is_empty()); diff --git a/src/command.rs b/src/command.rs index 25ccfb027..40299938e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1195,6 +1195,15 @@ pub struct LoftOptions { pub align_direction: bool, } +/// One face-selection edit made while collecting a solid shell operation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ShellFaceAction { + Remove(DVec3), + Add(DVec3), + RemoveAll, + AddAll, +} + impl Default for LoftOptions { fn default() -> Self { Self { @@ -1374,6 +1383,8 @@ pub enum CmdResult { Measurement(String), /// Print a measurement result and keep the command active. ReportMeasurement(String), + /// Print an input error and keep the command active. + ReportError(String), /// Print a measurement result, clear the current selection, and keep the command active. ReportMeasurementAndDeselect(String), /// Clear the current selection and keep the command active at its updated step. @@ -1554,6 +1565,12 @@ pub enum CmdResult { value: f64, fillet: bool, }, + /// Offset a solid and optionally open selected faces. + SolidShell { + handle: Handle, + actions: Vec, + distance: f64, + }, SolidSubtract { bases: Vec, cutters: Vec, diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..12ed38ea2 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 shell_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -90,6 +91,7 @@ impl CadModule for ModelModule { tools: vec![ RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("SHELL", "Shell", PRESSPULL_ICON)), ], }, ] diff --git a/src/modules/model/shell_cmd.rs b/src/modules/model/shell_cmd.rs new file mode 100644 index 000000000..6f73aae3a --- /dev/null +++ b/src/modules/model/shell_cmd.rs @@ -0,0 +1,265 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, ShellFaceAction}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShellStep { + SolidEditOption, + BodyOption, + SelectSolid, + RemoveFaces, + AddFaces, + Distance, +} + +pub struct ShellCommand { + entry_name: &'static str, + step: ShellStep, + target: Option, + actions: Vec, + default_distance: f64, +} + +impl ShellCommand { + pub fn direct(target: Option) -> Self { + Self { + entry_name: "SHELL", + step: if target.is_some() { + ShellStep::RemoveFaces + } else { + ShellStep::SelectSolid + }, + target, + actions: Vec::new(), + default_distance: 1.0, + } + } + + pub fn solid_edit(target: Option) -> Self { + Self { + entry_name: "SOLIDEDIT", + step: ShellStep::SolidEditOption, + target, + actions: Vec::new(), + default_distance: 1.0, + } + } + + fn finish(&mut self, distance: f64) -> CmdResult { + let Some(handle) = self.target else { + return CmdResult::Cancel; + }; + CmdResult::SolidShell { + handle, + actions: std::mem::take(&mut self.actions), + distance, + } + } + + fn undo_face_action(&mut self) -> CmdResult { + self.actions.pop(); + CmdResult::NeedPoint + } + + fn face_options(add: bool) -> Vec { + if add { + vec![ + CmdOption::new("Undo", "UNDO"), + CmdOption::new("Remove", "REMOVE"), + CmdOption::new("ALL", "ALL"), + ] + } else { + vec![ + CmdOption::new("Undo", "UNDO"), + CmdOption::new("Add", "ADD"), + CmdOption::new("ALL", "ALL"), + ] + } + } +} + +impl CadCommand for ShellCommand { + fn name(&self) -> &'static str { + self.entry_name + } + + fn prompt(&self) -> String { + match self.step { + ShellStep::SolidEditOption => { + crate::t!("Enter a solid editing option [Body/eXit]:").into_owned() + } + ShellStep::BodyOption => { + crate::t!("Enter a body editing option [Shell/eXit]:").into_owned() + } + ShellStep::SelectSolid => crate::t!("Select a 3D solid:").into_owned(), + ShellStep::RemoveFaces => crate::t!( + "Select faces to remove [Undo/Add/ALL] :" + ) + .into_owned(), + ShellStep::AddFaces => crate::t!( + "Select faces to add [Undo/Remove/ALL] :" + ) + .into_owned(), + ShellStep::Distance => crate::tf!( + "Specify the shell offset distance <{:.3}>:", + self.default_distance + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + ShellStep::SolidEditOption => vec![ + CmdOption::new("Body", "BODY"), + CmdOption::new("eXit", "EXIT"), + ], + ShellStep::BodyOption => vec![ + CmdOption::new("Shell", "SHELL"), + CmdOption::new("eXit", "EXIT"), + ], + ShellStep::RemoveFaces => Self::face_options(false), + ShellStep::AddFaces => Self::face_options(true), + _ => Vec::new(), + } + } + + fn needs_entity_pick(&self) -> bool { + matches!( + self.step, + ShellStep::SelectSolid | ShellStep::RemoveFaces | ShellStep::AddFaces + ) + } + + 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 handle.is_null() { + return CmdResult::NeedPoint; + } + match self.step { + ShellStep::SelectSolid => { + self.target = Some(handle); + self.step = ShellStep::RemoveFaces; + } + ShellStep::RemoveFaces if self.target == Some(handle) => { + self.actions.push(ShellFaceAction::Remove(point)); + } + ShellStep::AddFaces if self.target == Some(handle) => { + self.actions.push(ShellFaceAction::Add(point)); + } + _ => {} + } + CmdResult::NeedPoint + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = text.trim(); + let keyword = value.to_ascii_uppercase(); + let result = match self.step { + ShellStep::SolidEditOption => match keyword.as_str() { + "B" | "BODY" => { + self.step = ShellStep::BodyOption; + CmdResult::NeedPoint + } + "X" | "EXIT" => CmdResult::Cancel, + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::BodyOption => match keyword.as_str() { + "S" | "SHELL" => { + self.step = if self.target.is_some() { + ShellStep::RemoveFaces + } else { + ShellStep::SelectSolid + }; + CmdResult::NeedPoint + } + "X" | "EXIT" => CmdResult::Cancel, + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::RemoveFaces => match keyword.as_str() { + "U" | "UNDO" => return Some(self.undo_face_action()), + "A" | "ADD" => { + self.step = ShellStep::AddFaces; + CmdResult::NeedPoint + } + "ALL" => { + self.actions.push(ShellFaceAction::RemoveAll); + CmdResult::NeedPoint + } + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::AddFaces => match keyword.as_str() { + "U" | "UNDO" => return Some(self.undo_face_action()), + "R" | "REMOVE" => { + self.step = ShellStep::RemoveFaces; + CmdResult::NeedPoint + } + "ALL" => { + self.actions.push(ShellFaceAction::AddAll); + CmdResult::NeedPoint + } + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::Distance => { + let Ok(distance) = value.parse::() else { + return Some(CmdResult::ReportError( + crate::t!("A numeric shell offset distance is required.").into_owned(), + )); + }; + if !distance.is_finite() || distance.abs() <= f64::EPSILON { + return Some(CmdResult::ReportError( + crate::t!("Value must be nonzero.").into_owned(), + )); + } + self.default_distance = distance; + self.finish(distance) + } + ShellStep::SelectSolid => return None, + }; + Some(result) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + ShellStep::RemoveFaces | ShellStep::AddFaces => { + self.step = ShellStep::Distance; + CmdResult::NeedPoint + } + ShellStep::Distance => self.finish(self.default_distance), + _ => CmdResult::Cancel, + } + } + + fn on_undo_step(&mut self) -> Option { + matches!(self.step, ShellStep::RemoveFaces | ShellStep::AddFaces) + .then(|| self.undo_face_action()) + } + + fn input_kind(&self) -> InputKind { + if matches!(self.step, ShellStep::SelectSolid) { + InputKind::Point + } else { + InputKind::SingleToken + } + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SHELL", "SOLIDEDIT"] +}); diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index 5b74c78c6..c21c69364 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -177,6 +177,11 @@ pub fn tessellate_sat( color, None, )); + if let [single] = bodies.as_slice() { + if let Some(properties) = brep::analytic_mass_properties(&single.0) { + set.apply_mass_properties(properties); + } + } set.curved_gens = curved_gens; for point in edges { let high = [point[0] as f32, point[1] as f32, point[2] as f32]; diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 628b7c408..c3e4aff4c 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -55,6 +55,42 @@ pub struct MeshMetrics { pub surface_area: f64, pub volume: f64, pub centroid: [f64; 3], + pub moment_of_inertia: [f64; 3], + pub principal_directions: [f64; 9], + pub principal_moments: [f64; 3], + pub product_of_inertia: [f64; 3], + pub radii_of_gyration: [f64; 3], +} + +impl MeshMetrics { + pub fn translate(&mut self, delta: [f64; 3]) { + let old = self.centroid; + let new = [old[0] + delta[0], old[1] + delta[1], old[2] + delta[2]]; + if self.volume > 1e-18 { + self.moment_of_inertia[0] += self.volume + * (new[1] * new[1] + new[2] * new[2] + - old[1] * old[1] + - old[2] * old[2]); + self.moment_of_inertia[1] += self.volume + * (new[0] * new[0] + new[2] * new[2] + - old[0] * old[0] + - old[2] * old[2]); + self.moment_of_inertia[2] += self.volume + * (new[0] * new[0] + new[1] * new[1] + - old[0] * old[0] + - old[1] * old[1]); + self.product_of_inertia[0] -= + self.volume * (new[0] * new[1] - old[0] * old[1]); + self.product_of_inertia[1] -= + self.volume * (new[1] * new[2] - old[1] * old[2]); + self.product_of_inertia[2] -= + self.volume * (new[2] * new[0] - old[2] * old[0]); + self.radii_of_gyration = self + .moment_of_inertia + .map(|moment| (moment.max(0.0) / self.volume).sqrt()); + } + self.centroid = new; + } } #[derive(Clone, Debug)] @@ -160,6 +196,8 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics { let mut area_centroid_numerator = [0.0; 3]; let mut signed_volume = 0.0; let mut centroid_numerator = [0.0; 3]; + let mut squared_integrals = [0.0; 3]; + let mut product_integrals = [0.0; 3]; for triangle in mesh.indices.chunks_exact(3) { let a = point(triangle[0]); let b = point(triangle[1]); @@ -187,6 +225,25 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics { signed_volume += tetra; for axis in 0..3 { centroid_numerator[axis] += tetra * (a[axis] + b[axis] + c[axis]) / 4.0; + squared_integrals[axis] += tetra + * (a[axis] * a[axis] + + b[axis] * b[axis] + + c[axis] * c[axis] + + a[axis] * b[axis] + + a[axis] * c[axis] + + b[axis] * c[axis]) + / 10.0; + } + for (index, (first, second)) in [(0usize, 1usize), (1, 2), (2, 0)] + .into_iter() + .enumerate() + { + let diagonal = a[first] * a[second] + + b[first] * b[second] + + c[first] * c[second]; + let all = (a[first] + b[first] + c[first]) + * (a[second] + b[second] + c[second]); + product_integrals[index] += tetra * (diagonal + all) / 20.0; } } let centroid = if signed_volume.abs() > 1e-18 { @@ -204,16 +261,120 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics { } else { [0.0; 3] }; + let orientation = if signed_volume < 0.0 { -1.0 } else { 1.0 }; + let volume = signed_volume.abs(); + let square = squared_integrals.map(|value| value * orientation); + let products = product_integrals.map(|value| -value * orientation); + let moment_of_inertia = [ + square[1] + square[2], + square[0] + square[2], + square[0] + square[1], + ]; + let central = [ + [ + moment_of_inertia[0] - volume * (centroid[1].powi(2) + centroid[2].powi(2)), + products[0] + volume * centroid[0] * centroid[1], + products[2] + volume * centroid[0] * centroid[2], + ], + [ + products[0] + volume * centroid[0] * centroid[1], + moment_of_inertia[1] - volume * (centroid[0].powi(2) + centroid[2].powi(2)), + products[1] + volume * centroid[1] * centroid[2], + ], + [ + products[2] + volume * centroid[0] * centroid[2], + products[1] + volume * centroid[1] * centroid[2], + moment_of_inertia[2] - volume * (centroid[0].powi(2) + centroid[1].powi(2)), + ], + ]; + let (principal_moments, principal_directions) = principal_axes(central); + let radii_of_gyration = if volume > 1e-18 { + moment_of_inertia.map(|moment| (moment.max(0.0) / volume).sqrt()) + } else { + [0.0; 3] + }; MeshMetrics { vertices: mesh.verts.len(), triangles: mesh.indices.len() / 3, surface_area: area, - volume: signed_volume.abs(), + volume, centroid, + moment_of_inertia, + principal_directions, + principal_moments, + product_of_inertia: products, + radii_of_gyration, } } +fn principal_axes(mut matrix: [[f64; 3]; 3]) -> ([f64; 3], [f64; 9]) { + let mut vectors = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + for _ in 0..24 { + let (p, q) = [(0usize, 1usize), (0, 2), (1, 2)] + .into_iter() + .max_by(|&(ap, aq), &(bp, bq)| { + matrix[ap][aq].abs().total_cmp(&matrix[bp][bq].abs()) + }) + .unwrap_or((0, 1)); + if matrix[p][q].abs() <= 1e-12 { + break; + } + let angle = 0.5 * (2.0 * matrix[p][q]).atan2(matrix[q][q] - matrix[p][p]); + let (sine, cosine) = angle.sin_cos(); + for row in 0..3 { + let mp = matrix[row][p]; + let mq = matrix[row][q]; + matrix[row][p] = cosine * mp - sine * mq; + matrix[row][q] = sine * mp + cosine * mq; + } + for column in 0..3 { + let mp = matrix[p][column]; + let mq = matrix[q][column]; + matrix[p][column] = cosine * mp - sine * mq; + matrix[q][column] = sine * mp + cosine * mq; + } + for row in 0..3 { + let vp = vectors[row][p]; + let vq = vectors[row][q]; + vectors[row][p] = cosine * vp - sine * vq; + vectors[row][q] = sine * vp + cosine * vq; + } + } + let mut axes = (0..3) + .map(|axis| { + ( + matrix[axis][axis].max(0.0), + [vectors[0][axis], vectors[1][axis], vectors[2][axis]], + ) + }) + .collect::>(); + axes.sort_by(|left, right| left.0.total_cmp(&right.0)); + let moments = [axes[0].0, axes[1].0, axes[2].0]; + let scale = moments[2].abs().max(1.0); + if (moments[2] - moments[0]).abs() <= scale * 1e-10 { + return ( + moments, + [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + ); + } + let directions = [ + axes[0].1[0], axes[0].1[1], axes[0].1[2], axes[1].1[0], axes[1].1[1], + axes[1].1[2], axes[2].1[0], axes[2].1[1], axes[2].1[2], + ]; + (moments, directions) +} + impl MeshLodSet { + pub fn apply_mass_properties(&mut self, properties: cadkernel::brep::MassProperties) { + self.metrics.volume = properties.volume; + self.metrics.centroid = properties.centroid; + self.metrics.moment_of_inertia = properties.moment_of_inertia; + self.metrics.principal_directions = properties.principal_directions; + self.metrics.principal_moments = properties.principal_moments; + self.metrics.product_of_inertia = properties.product_of_inertia; + self.metrics.radii_of_gyration = properties.radii_of_gyration; + } + /// Build a set from its LODs, computing the 3D AABB. pub fn from_lods(lods: Vec) -> Self { let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index c52d28732..7b0fc6909 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -996,9 +996,44 @@ fn brep_properties( history_flags(document, handle).unwrap_or((false, false, 1)); let (show_history, show_history_editable) = displayed_history_state(object_show_history, show_history_mode); - vec![PropSection { - title: t!("Solid History").into_owned(), - props: vec![ + let mut geometry = vec![Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("3D Solid").into_owned()), + }]; + if let Some(position) = document + .solid_history_operation(handle) + .and_then(|operation| match operation { + SolidHistoryOperation::Brep(value) => world_point(value.base.transform, [0.0; 3]), + _ => None, + }) + { + geometry.extend([ + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + ]); + } + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: geometry, + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ Property { label: t!("History").into_owned(), field: PROP_HISTORY, @@ -1020,7 +1055,8 @@ fn brep_properties( }, }, ], - }] + }, + ] } fn cylinder_properties( @@ -2186,11 +2222,50 @@ fn set_pyramid_sides(value: &mut SolidHistoryPyramid, sides: i32) -> bool { true } +fn apply_brep_position_property( + value: &mut SolidHistoryBrep, + field: &str, + text: &str, +) -> Option { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + let Some(target) = crate::entities::common::parse_length(text) else { + return Some(false); + }; + if !target.is_finite() { + return Some(false); + } + let Some(current) = matrix(value.base.transform) else { + return Some(false); + }; + let position = current.transform_point3(glam::DVec3::ZERO); + let mut next = position; + next[axis] = target; + if next == position { + return Some(true); + } + let updated = glam::DMat4::from_translation(next - position) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) +} + pub fn apply_primitive_property( operation: &mut SolidHistoryOperation, field: &str, value: &str, ) -> bool { + if let SolidHistoryOperation::Brep(brep_value) = operation { + if let Some(applied) = apply_brep_position_property(brep_value, field, value) { + return applied; + } + } if let SolidHistoryOperation::Box(rectangular_value) | SolidHistoryOperation::Wedge(rectangular_value) = operation { diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index d6e8c6d9a..a2558419c 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -337,6 +337,18 @@ pub fn nearest_planar_face(body: &Body, pick: [f64; 3]) -> Option { matches!(body.surfaces.get(node.surface)?, Surface::Plane(_)).then_some(face) } +/// Face nearest a world-space surface pick, including curved surfaces. +pub fn nearest_face(body: &Body, pick: [f64; 3]) -> Option { + body.face_keys() + .filter_map(|key| { + let face = body.faces.get(key)?; + let surface = body.surfaces.get(face.surface)?; + Some((key, surface.distance_to(pick).abs())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key) +} + /// Outward normal of a planar face. pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { let face = body.faces.get(face)?; @@ -475,7 +487,11 @@ pub fn display_from_solid( ) }) .collect(); - Some((mesh_from_tessellation(tessellation, color)?, wires, center)) + let mut mesh = mesh_from_tessellation(tessellation, color)?; + if let Some(properties) = cadkernel::brep::analytic_mass_properties(body) { + mesh.apply_mass_properties(properties); + } + Some((mesh, wires, center)) } /// The middle of a body, for a caller needing a point to turn or scale about. diff --git a/src/scene/modify.rs b/src/scene/modify.rs index f84605425..8059b1e6f 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1235,9 +1235,7 @@ impl Scene { generator.source = source; } } - set.metrics.centroid[0] += delta[0]; - set.metrics.centroid[1] += delta[1]; - set.metrics.centroid[2] += delta[2]; + set.metrics.translate(delta); set.recompute_aabb(); } From 2d55b90b0ec735818cd764ceacd19ecf1c02d212 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 22:20:07 +0300 Subject: [PATCH 68/71] chore: update DWG save dependencies --- 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 ffb760230..e283b7d70 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=db2eae87f759f724115d5d7a7b3e7c44f9759b64#db2eae87f759f724115d5d7a7b3e7c44f9759b64" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=1e7808d719c387a9cf26850683991980bf82667f#1e7808d719c387a9cf26850683991980bf82667f" 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=20a4dfa75f5a00a4ce28f07a797134810f4006b0#20a4dfa75f5a00a4ce28f07a797134810f4006b0" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=304a375be1f73945af24e6e0f5b4664417a9d9e0#304a375be1f73945af24e6e0f5b4664417a9d9e0" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 2e9f434fd..0ce09df17 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 = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "20a4dfa75f5a00a4ce28f07a797134810f4006b0", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "304a375be1f73945af24e6e0f5b4664417a9d9e0", 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 6cdfcbb52..4a4f25f2f 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 = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "20a4dfa75f5a00a4ce28f07a797134810f4006b0", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "304a375be1f73945af24e6e0f5b4664417a9d9e0", 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 76f070754..199f20772 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 = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", 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 = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 60af2e4c4..bbe2893cf 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 = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", 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 a802c2941..ddee4d5ff 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 = "db2eae87f759f724115d5d7a7b3e7c44f9759b64", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } From 89572e5ca982a313490ceeabf66d3c2da2492b75 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 22:42:49 +0300 Subject: [PATCH 69/71] fix: keep valid small drawing curves on open --- src/io/mod.rs | 90 ++++++++++++++------------------------------------- 1 file changed, 25 insertions(+), 65 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index a1713d21c..ab117b1f4 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -2078,33 +2078,8 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { || !finite_coord(a.radius) || !a.start_angle.is_finite() || !a.end_angle.is_finite() - // Same degenerate-curve guard as Circle. || a.radius.abs() < 1.0e-10 || a.radius.abs() > 1.0e10 - // Zero-sweep arc (start_angle == end_angle, modulo 2π) collapses - // to a single point in WCS — a three-point circle fit on - // coincident vertices recurses unboundedly in parameter_division. - || (a.end_angle - a.start_angle).abs() < 1.0e-9 - // Near-zero sweep is the same trap with a wider mouth: a tiny but - // non-zero sweep (e.g. 1.6e-6 rad) still places start/mid/end - // within the coincidence tolerance, so sampling - // recurses and allocates until OOM. Gate on arc *length* - // (radius × sweep), not sweep alone, so a legitimately large- - // radius small-sweep arc (still a visible curve) survives while - // sub-precision arcs are dropped. - || a.radius.abs() * (a.end_angle - a.start_angle).abs() < 1.0e-6 - // Near-collinear sample points: even when the arc *length* clears - // the floor above, a small sweep over a modest radius leaves - // start/mid/end almost on one line (a 35-unit, 6.5e-7-rad arc has - // arc length 2.3e-5 — past the gate — yet bows off its chord by - // only ~2e-12). A three-point circle fit then returns a - // near-infinite radius and `parameter_division` subdivides without - // bound. Gate on the sagitta (chord height = r·(1−cos(sweep/2))), - // the true measure of how far the arc departs a straight line and - // of the fit's conditioning. - || a.radius.abs() - * (1.0 - ((a.end_angle - a.start_angle).abs() * 0.5).cos()) - < 1.0e-6 || !finite_unit_normal(&a.normal) } E::Ellipse(e) => { @@ -2134,32 +2109,15 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { let n = s.control_points.len(); let degree_bad = s.degree < 1; let deg = s.degree.max(0) as usize; + let compact_periodic_knots = s.flags.periodic && s.knots.len() == n.saturating_add(1); let knots_bad = !s.knots.is_empty() && (s.knots.iter().any(|k| !k.is_finite()) || s.knots.windows(2).any(|w| w[1] < w[0]) - || s.knots.len() != n + deg + 1); - // Degenerate: every control point collapses onto (nearly) the same - // point, so the curve has zero length. A three-point fit - // `parameter_division` never converges on it and the tessellation - // hangs — a periodic 9-point spline pinned at the origin is the seen - // case. Reject when the control-point extent is sub-precision. - let degenerate_extent = n >= 2 && { - let (mut mn, mut mx) = ([f64::MAX; 3], [f64::MIN; 3]); - for p in &s.control_points { - mn[0] = mn[0].min(p.x); - mx[0] = mx[0].max(p.x); - mn[1] = mn[1].min(p.y); - mx[1] = mx[1].max(p.y); - mn[2] = mn[2].min(p.z); - mx[2] = mx[2].max(p.z); - } - (mx[0] - mn[0]).max(mx[1] - mn[1]).max(mx[2] - mn[2]) < 1.0e-6 - }; + || (!compact_periodic_knots && s.knots.len() != n + deg + 1)); n >= MAX_VERTS || degree_bad || s.control_points.iter().any(|p| !finite_vec3(p)) || knots_bad - || degenerate_extent } _ => false, } @@ -2373,38 +2331,29 @@ mod corrupt_guard_tests { use acadrust::entities::{Arc, EntityType, Spline}; use acadrust::types::Vector3; - // A near-zero-sweep arc: sweep 1.56e-6 rad on a 3.9e-3 radius. The angles - // are individually finite and the radius is in range, so the old - // (end-start) < 1e-9 check passed it through — but start/mid/end land - // within the coincidence tolerance and sampling allocates - // until OOM. The arc-length floor must reject it. + // Small but finite arcs are valid records. Kernel tessellation is bounded, + // so opening must retain them instead of treating their size as corruption. #[test] - fn rejects_near_degenerate_arc() { + fn keeps_small_finite_arc() { let mut a = Arc::new(); a.center = Vector3::new(2880.84, 891.83, 0.0); a.radius = 0.0038974142851181423; a.start_angle = 1.0401656235942365; a.end_angle = 1.0401671831670538; a.normal = Vector3::new(0.0, 0.0, 1.0); - assert!(is_entity_corrupt(&EntityType::Arc(a))); + assert!(!is_entity_corrupt(&EntityType::Arc(a))); } - // A 35-unit-radius arc sweeping 6.5e-7 rad has arc length 2.3e-5 — past the - // arc-length floor — yet its start/mid/end bow off the chord by only ~2e-12, - // so a three-point circle fit blows up and sampling hangs. The - // sagitta floor must reject it where the arc-length floor alone does not. + // A nearly straight arc still carries valid source geometry. #[test] - fn rejects_near_collinear_arc() { + fn keeps_nearly_straight_arc() { let mut a = Arc::new(); a.center = Vector3::new(551435.3071786845, 4051623.7156955916, 0.0); a.radius = 35.0; a.start_angle = 5.823361856481176; a.end_angle = 5.823362506017916; a.normal = Vector3::new(0.0, 0.0, 1.0); - // Sanity: arc length clears the old gate, proving the sagitta gate is - // what catches this one. - assert!(a.radius * (a.end_angle - a.start_angle).abs() > 1.0e-6); - assert!(is_entity_corrupt(&EntityType::Arc(a))); + assert!(!is_entity_corrupt(&EntityType::Arc(a))); } // A large-radius small-sweep arc is still a visible curve and must survive: @@ -2428,14 +2377,25 @@ mod corrupt_guard_tests { assert!(is_entity_corrupt(&EntityType::Spline(s))); } - // A periodic spline whose control points all collapse onto (nearly) one - // point has zero length; sampling never converges and the - // tessellation hangs. The control-point extent floor must reject it. + // A collapsed spline is still a valid source record. Kernel subdivision + // has a fixed depth bound and can process it safely. #[test] - fn rejects_degenerate_point_spline() { + fn keeps_degenerate_point_spline() { let pts = vec![Vector3::new(1e-12, -1e-12, 0.0); 9]; let s = Spline::from_control_points(3, pts); - assert!(is_entity_corrupt(&EntityType::Spline(s))); + assert!(!is_entity_corrupt(&EntityType::Spline(s))); + } + + #[test] + fn keeps_compact_periodic_spline_knots() { + let pts = (0..12) + .map(|index| Vector3::new(index as f64, (index % 3) as f64, 0.0)) + .collect(); + let mut s = Spline::from_control_points(2, pts); + s.flags.closed = true; + s.flags.periodic = true; + s.knots = (0..13).map(|value| value as f64).collect(); + assert!(!is_entity_corrupt(&EntityType::Spline(s))); } // A normal cubic spline (4 control points, valid clamped knots) survives. From 8f574c83881ceb19ba50c5f98c1021b4f2145b8c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 8 Sep 2026 23:59:13 +0300 Subject: [PATCH 70/71] feat(mcp): expose complete drawing records --- 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 +- docs/automation/README.md | 50 +- docs/automation/mcp_smoke.py | 5 +- src/app/automation.rs | 296 +++++-- src/app/control/mod.rs | 39 +- src/app/mod.rs | 125 +-- src/app/record_api.rs | 1027 ++++++++++++++++++++++++ src/lib.rs | 17 +- src/mcp.rs | 100 ++- 14 files changed, 1492 insertions(+), 187 deletions(-) create mode 100644 src/app/record_api.rs diff --git a/Cargo.lock b/Cargo.lock index e283b7d70..34c162522 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=1e7808d719c387a9cf26850683991980bf82667f#1e7808d719c387a9cf26850683991980bf82667f" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7b4c112bd7895fe9039514c21c5b5aa59eaf2525#7b4c112bd7895fe9039514c21c5b5aa59eaf2525" 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=304a375be1f73945af24e6e0f5b4664417a9d9e0#304a375be1f73945af24e6e0f5b4664417a9d9e0" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8efd43cb2f04577d20c3ee33ce5273bb49affa3d#8efd43cb2f04577d20c3ee33ce5273bb49affa3d" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 0ce09df17..6079da7ee 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 = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "304a375be1f73945af24e6e0f5b4664417a9d9e0", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", 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 4a4f25f2f..27ea492e4 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 = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "304a375be1f73945af24e6e0f5b4664417a9d9e0", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", 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 199f20772..f04715b36 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 = "1e7808d719c387a9cf26850683991980bf82667f", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", 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 = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index bbe2893cf..9ff671577 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 = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", 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 ddee4d5ff..888cc84eb 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 = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } diff --git a/docs/automation/README.md b/docs/automation/README.md index c1e200e3b..a3646a89d 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -13,8 +13,8 @@ The client must start that command over standard input/output. If it asks for th The server provides four tools: - `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists. -- `ocs_read` reads document state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events, and operation status. -- `ocs_execute` performs one operation or a sequential batch against the real editor. +- `ocs_read` discovers capabilities and reads document state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events, and operation status. +- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor. - `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: @@ -31,6 +31,50 @@ The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `oc } ``` +Call `ocs_read` with `op: "capabilities"` before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The `records.collections` list gives every available database collection, its record count, and whether it can be edited. + +`op: "records"` returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting `collection` returns the same collection manifest; use `collection: "all"` for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and `properties`. Entity records also include their display and file record type names. + +Property filters and projections use RFC 6901 JSON Pointer paths relative to `properties`. Multiple `where` filters are combined with AND. Supported operators are `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `starts_with`, `ends_with`, `in`, `exists`, and `not_exists`: + +```json +{ + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "records", + "parameters": { + "collection": "entities", + "type": "Insert", + "where": [ + {"path": "/common/layer", "value": "Equipment"}, + {"path": "/attributes/0/tag", "value": "COMPANY"} + ], + "paths": ["/block_name", "/insert_point", "/attributes"] + } +} +``` + +Use `set_properties` to replace one or more fields atomically. OCS verifies the record identity, JSON types, optional compare-and-set values, layer locks, document revision, and request ID before committing one undoable edit. Handles, ownership slots, table names, and other database identity fields are read-only; their dedicated editor commands preserve cross-record references. + +```json +{ + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", + "request": { + "op": "set_properties", + "request_id": "attributes-1", + "document_id": 1, + "revision": 12, + "collection": "entities", + "handle": "2A", + "updates": [ + {"path": "/attributes/0/value", "expected": "BPH", "value": "Updated"}, + {"path": "/insert_point/z", "value": 3.0} + ] + } +} +``` + +The same operation edits entries in `objects`, every symbol table, `header`, and `summary_info`. Collections marked `mutable: false` are derived or storage metadata and remain queryable. Their source records must be changed so OCS can rebuild those views consistently. + To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance. A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token: @@ -67,7 +111,7 @@ Use `batch` when the steps are already known. OCS supplies each step with the st Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed. -`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: +`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. `detail: "full"` adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: ```json { diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index f523b21d0..d86a83887 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,9 +52,12 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 15 + assert len(execute_request["oneOf"]) == 16 assert execute_request["properties"]["steps"]["maxItems"] == 64 assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + assert "set_properties" in execute_request["properties"]["op"]["enum"] + read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"] + assert {"collection", "where", "paths"} <= set(read_parameters) assert execute_request["properties"]["kind"]["enum"] == [ "text", "token", "point", "entity", "structure", "selection", "enter" ] diff --git a/src/app/automation.rs b/src/app/automation.rs index 6d7b4b05e..3b03c756e 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -18,7 +18,7 @@ use std::io::{BufRead, Write}; use std::path::PathBuf; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use super::OpenCADStudio; @@ -138,6 +138,14 @@ fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } +fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool { + if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) { + return true; + } + let record_name = crate::entities::names::dxf_name(entity); + record_name != "ENTITY" && record_name.eq_ignore_ascii_case(requested) +} + /// One entity as JSON. Summary mode carries identity only, geometry adds the /// entity's defining values, and full also includes its world bounds. fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { @@ -194,15 +202,30 @@ fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { } E::Insert(ins) => { map.insert("block".into(), json!(ins.block_name)); + map.insert("position".into(), v3(ins.insert_point)); + let attributes: serde_json::Map = ins + .attributes + .iter() + .map(|attribute| (attribute.tag.clone(), json!(attribute.value))) + .collect(); + map.insert("attributes".into(), Value::Object(attributes)); } _ => {} } if detail == "full" { let bounds = e.as_entity().bounding_box(); - map.insert("bounds".into(), json!({ - "min":[bounds.min.x,bounds.min.y,bounds.min.z], - "max":[bounds.max.x,bounds.max.y,bounds.max.z] - })); + map.insert( + "bounds".into(), + json!({ + "min":[bounds.min.x,bounds.min.y,bounds.min.z], + "max":[bounds.max.x,bounds.max.y,bounds.max.z] + }), + ); + if let Ok(Value::Object(wrapper)) = serde_json::to_value(e) { + if let Some((_, properties)) = wrapper.into_iter().next() { + map.insert("properties".into(), properties); + } + } } obj } @@ -218,16 +241,24 @@ fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> { } fn request_handle(value: &Value) -> Option { - value.as_str() - .and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok()) + value + .as_str() + .and_then(|value| { + let value = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value); + u64::from_str_radix(value, 16).ok() + }) .map(acadrust::Handle::new) } fn projected_fields(mut entity: Value, fields: Option<&Vec>) -> Value { let Some(fields) = fields else { return entity }; - let Some(source) = entity.as_object_mut() else { return entity }; - let keep: std::collections::HashSet<&str> = - fields.iter().filter_map(Value::as_str).collect(); + let Some(source) = entity.as_object_mut() else { + return entity; + }; + let keep: std::collections::HashSet<&str> = fields.iter().filter_map(Value::as_str).collect(); source.retain(|key, _| key == "handle" || keep.contains(key.as_str())); entity } @@ -244,7 +275,9 @@ impl OpenCADStudio { return err(error); } if matches!(response["status"].as_str(), Some("accepted" | "running")) { - return self.control_request(json!({"op":"operation","request_id":id})).0; + return self + .control_request(json!({"op":"operation","request_id":id})) + .0; } return response; } @@ -316,7 +349,9 @@ impl OpenCADStudio { let i = self.active_tab; let before = self.tabs[i].scene.document.entities().count(); let error_revision = self.command_line.error_revision; - if let Err(error) = self.run_headless(&cmd) { return err(error); } + if let Err(error) = self.run_headless(&cmd) { + return err(error); + } if self.command_line.error_revision != error_revision { return err(self.command_line.last_error.clone().unwrap_or_default()); } @@ -331,13 +366,14 @@ impl OpenCADStudio { } "entities" => self.entity_summary(), "query" => self.entity_query(&req), + "records" => self.record_query(&req), + "capabilities" => self.record_capabilities(), "layers" => { let i = self.active_tab; let offset = req["offset"].as_u64().unwrap_or(0) as usize; let limit = req["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; let count = self.tabs[i].scene.document.layers.iter().count(); - let layers: Vec = self - .tabs[i] + let layers: Vec = self.tabs[i] .scene .document .layers @@ -397,8 +433,14 @@ impl OpenCADStudio { // By explicit handles (hex, as returned by `query`). if let Some(arr) = req["handles"].as_array() { for h in arr.iter().filter_map(|h| h.as_str()) { - if let Ok(v) = u64::from_str_radix(h.trim_start_matches("0x"), 16) { - self.tabs[i].scene.select_entity(acadrust::Handle::new(v), false); + let h = h + .strip_prefix("0x") + .or_else(|| h.strip_prefix("0X")) + .unwrap_or(h); + if let Ok(v) = u64::from_str_radix(h, 16) { + self.tabs[i] + .scene + .select_entity(acadrust::Handle::new(v), false); } } } @@ -410,11 +452,7 @@ impl OpenCADStudio { .scene .document .entities() - .filter(|e| { - type_filter.is_none_or(|t| { - crate::entities::names::ui_name(e).eq_ignore_ascii_case(t) - }) - }) + .filter(|e| type_filter.is_none_or(|t| entity_type_matches(e, t))) .filter(|e| layer_filter.is_none_or(|l| e.common().layer == l)) .map(|e| e.common().handle) .collect(); @@ -435,8 +473,7 @@ impl OpenCADStudio { return err("save: no \"path\" and the document has none"); }; #[cfg(not(target_arch = "wasm32"))] - let result = - self.save_tab_synchronously_protected(i, path.clone(), true); + let result = self.save_tab_synchronously_protected(i, path.clone(), true); #[cfg(target_arch = "wasm32")] let result = crate::io::save(&self.tabs[i].scene.document, &path) .map_err(crate::io::SaveFailure::other); @@ -461,19 +498,30 @@ impl OpenCADStudio { self.drive_headless_task(task) } - pub(super) fn drive_headless_task(&mut self, task: iced::Task) -> Result<(), String> { + pub(super) fn drive_headless_task( + &mut self, + task: iced::Task, + ) -> Result<(), String> { use iced::futures::StreamExt; let mut streams = Vec::new(); - if let Some(stream) = iced_runtime::task::into_stream(task) { streams.push(stream); } + if let Some(stream) = iced_runtime::task::into_stream(task) { + streams.push(stream); + } while let Some(stream) = streams.last_mut() { match iced::futures::executor::block_on(stream.next()) { Some(iced_runtime::Action::Output(message)) => { let next = self.update(message); - if let Some(stream) = iced_runtime::task::into_stream(next) { streams.push(stream); } + if let Some(stream) = iced_runtime::task::into_stream(next) { + streams.push(stream); + } } - Some(iced_runtime::Action::Widget(_)) | Some(iced_runtime::Action::Tick) | Some(iced_runtime::Action::Reload) => {}, + Some(iced_runtime::Action::Widget(_)) + | Some(iced_runtime::Action::Tick) + | Some(iced_runtime::Action::Reload) => {} Some(action) => return Err(format!("GUI runtime required for {action:?}")), - None => { streams.pop(); } + None => { + streams.pop(); + } } } self.finish_all_pending_history(); @@ -529,20 +577,36 @@ impl OpenCADStudio { let layer_filter = req["layer"].as_str(); let handles: Option> = req["handles"] .as_array() - .map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect()) - .or_else(|| request_handle(&req["handle"]) - .map(|handle| std::iter::once(handle.value()).collect())); + .map(|values| { + values + .iter() + .filter_map(request_handle) + .map(|h| h.value()) + .collect() + }) + .or_else(|| { + request_handle(&req["handle"]) + .map(|handle| std::iter::once(handle.value()).collect()) + }); let near = request_point(req, "near"); let contains = request_point(req, "contains_point"); let bounds = req["bounds"].as_array().and_then(|values| { - (values.len() == 4).then(|| Some([ - values[0].as_f64()?, values[1].as_f64()?, - values[2].as_f64()?, values[3].as_f64()?, - ])).flatten() + (values.len() == 4) + .then(|| { + Some([ + values[0].as_f64()?, + values[1].as_f64()?, + values[2].as_f64()?, + values[3].as_f64()?, + ]) + }) + .flatten() }); - if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| { - parsed.len() != req["handles"].as_array().map_or(0, Vec::len) - }) { + if req.get("handles").is_some() + && handles + .as_ref() + .is_some_and(|parsed| parsed.len() != req["handles"].as_array().map_or(0, Vec::len)) + { return err("query handles contains an invalid hexadecimal handle"); } if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() { @@ -554,10 +618,13 @@ impl OpenCADStudio { if req.get("contains_point").is_some() && contains.is_none() { return err("query contains_point expects two or three finite coordinates"); } - if req.get("bounds").is_some() && bounds.is_none_or(|bounds| { - !bounds.iter().all(|value| value.is_finite()) - || bounds[0] > bounds[2] || bounds[1] > bounds[3] - }) { + if req.get("bounds").is_some() + && bounds.is_none_or(|bounds| { + !bounds.iter().all(|value| value.is_finite()) + || bounds[0] > bounds[2] + || bounds[1] > bounds[3] + }) + { return err("query bounds expects finite [min_x,min_y,max_x,max_y]"); } let detail = req["detail"].as_str().unwrap_or("geometry"); @@ -569,39 +636,47 @@ impl OpenCADStudio { let mut matched = Vec::new(); for e in tab.scene.document.entities() { - if handles.as_ref().is_some_and(|handles| { - !handles.contains(&e.common().handle.value()) - }) { + if handles + .as_ref() + .is_some_and(|handles| !handles.contains(&e.common().handle.value())) + { continue; } - if type_filter.is_some_and(|value| { - !crate::entities::names::ui_name(e).eq_ignore_ascii_case(value) - }) || layer_filter.is_some_and(|value| e.common().layer != value) { + if type_filter.is_some_and(|value| !entity_type_matches(e, value)) + || layer_filter.is_some_and(|value| e.common().layer != value) + { continue; } if let Some(bounds) = bounds { let entity_bounds = e.as_entity().bounding_box(); - if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1] - || entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3] + if entity_bounds.max.x < bounds[0] + || entity_bounds.max.y < bounds[1] + || entity_bounds.min.x > bounds[2] + || entity_bounds.min.y > bounds[3] { continue; } } let curve = (near.is_some() || contains.is_some()) - .then(|| crate::entities::curve::entity_curve_xy(e)).flatten(); + .then(|| crate::entities::curve::entity_curve_xy(e)) + .flatten(); if let Some(point) = contains { let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else { continue; }; if !cadkernel::geom2d::contains( - std::slice::from_ref(curve), point, + std::slice::from_ref(curve), + point, cadkernel::geom2d::Tolerance::default(), ) { continue; } } - let nearest = near.and_then(|point| curve.as_ref() - .map(|curve| cadkernel::geom2d::closest_point(curve, point))); + let nearest = near.and_then(|point| { + curve + .as_ref() + .map(|curve| cadkernel::geom2d::closest_point(curve, point)) + }); if near.is_some() && nearest.is_none() { continue; } @@ -609,20 +684,29 @@ impl OpenCADStudio { if let Some(nearest) = nearest { let object = entity.as_object_mut().expect("entity JSON object"); object.insert("distance".into(), json!(nearest.distance)); - object.insert("closest_point".into(), - json!([nearest.point[0], nearest.point[1]])); + object.insert( + "closest_point".into(), + json!([nearest.point[0], nearest.point[1]]), + ); object.insert("parameter".into(), json!(nearest.t)); } matched.push((nearest.map(|nearest| nearest.distance), entity)); } if near.is_some() { - matched.sort_by(|left, right| left.0.partial_cmp(&right.0) - .unwrap_or(std::cmp::Ordering::Equal)); + matched.sort_by(|left, right| { + left.0 + .partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal) + }); } let count = matched.len(); let fields = req["fields"].as_array(); - let entities: Vec = matched.into_iter().skip(offset).take(limit) - .map(|(_, entity)| projected_fields(entity, fields)).collect(); + let entities: Vec = matched + .into_iter() + .skip(offset) + .take(limit) + .map(|(_, entity)| projected_fields(entity, fields)) + .collect(); json!({ "ok": true, "document_id":tab.id, @@ -657,7 +741,10 @@ mod tests { fn layout_notice_skips_grid_camera_and_scene_builds() { let mut app = OpenCADStudio::new_for_test(); assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); - assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], + true + ); let i = app.active_tab; let scene = &mut app.tabs[i].scene; scene.document.add_layout("Review").unwrap(); @@ -738,6 +825,44 @@ mod tests { assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false); } + #[test] + fn block_reference_query_exposes_instance_attributes() { + use acadrust::entities::{AttributeEntity, EntityType, Insert}; + use acadrust::types::Vector3; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0)); + insert.common.layer = "Equipment".to_string(); + insert + .attributes + .push(AttributeEntity::simple("COMPANY", "BPH")); + insert + .attributes + .push(AttributeEntity::simple("STATUS", "ACTIVE")); + app.tabs[i].scene.add_entity(EntityType::Insert(insert)); + + let result = app + .automation_op(r#"{"op":"query","type":"Insert","layer":"Equipment","detail":"full"}"#); + assert_eq!(result["count"], 1); + assert_eq!(result["entities"][0]["type"], "Block Reference"); + assert_eq!(result["entities"][0]["block"], "A_CPT"); + assert_eq!( + result["entities"][0]["position"], + serde_json::json!([12.0, 34.0, 5.0]) + ); + assert_eq!(result["entities"][0]["attributes"]["COMPANY"], "BPH"); + assert_eq!(result["entities"][0]["attributes"]["STATUS"], "ACTIVE"); + assert_eq!( + result["entities"][0]["properties"]["attributes"][0]["value"], + "BPH" + ); + + let selected = app.automation_op(r#"{"op":"select","type":"Insert"}"#); + assert_eq!(selected["selected"], 1); + } + #[test] fn ucs_interactive_inline_args() { // `UCS Z 90` must drive the interactive UCS command step-by-step (option @@ -789,9 +914,7 @@ mod tests { fn tilted_ucs_places_planar_entities_with_the_plane_normal() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); - app.automation_op( - r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#, - ); + app.automation_op(r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#); let circle = app.tabs[app.active_tab] @@ -831,7 +954,10 @@ mod tests { h.linetype_scale ); // No command should be left dangling. - assert!(app.tabs[i].active_cmd.is_none(), "command must have finished"); + assert!( + app.tabs[i].active_cmd.is_none(), + "command must have finished" + ); } #[test] @@ -993,7 +1119,9 @@ mod tests { (20.0, 300.0), ] }; - let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1))); + let _ = app.update(Message::ViewportMove(iced::Point::new( + path[0].0, path[0].1, + ))); let _ = app.update(Message::ViewportLeftPress); std::thread::sleep(std::time::Duration::from_millis(180)); for &(x, y) in &path { @@ -1036,7 +1164,10 @@ mod tests { // Two-step: bare command then the value, like typing 1 + Enter // (feed_active_cmd is the same path the GUI submit offers first). let _ = app.run_command_line("PICKDRAG"); - assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open"); + assert!( + app.tabs[app.active_tab].active_cmd.is_some(), + "prompt must open" + ); let _ = app.feed_active_cmd("1"); assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch"); } @@ -1074,10 +1205,7 @@ mod tests { let _ = app.run_command_line("MATCHPROP"); assert!(app.tabs[i].active_cmd.is_some(), "MATCHPROP must start"); let _ = app.feed_command(StepInput::EntityPick(src_h, glam::DVec3::ZERO)); - let _ = app.feed_command(StepInput::SelectionComplete(vec![ - dst_text_h, - dst_mtext_h, - ])); + let _ = app.feed_command(StepInput::SelectionComplete(vec![dst_text_h, dst_mtext_h])); let doc = &app.tabs[i].scene.document; match doc.get_entity(dst_text_h) { @@ -1129,7 +1257,10 @@ mod tests { app.opening.is_none(), "an already-open drawing must not start a load" ); - assert!(app.pending_opens.is_empty(), "and must not queue one either"); + assert!( + app.pending_opens.is_empty(), + "and must not queue one either" + ); // The same file spelled differently (a `..` hop) is still the same file. let indirect = canon.parent().unwrap().join("..").join( @@ -1223,8 +1354,7 @@ mod tests { assert_eq!(saved["ok"], true, "{label}: {}", saved["error"]); let saved_again = app.automation_op(r#"{"op":"save"}"#); assert_eq!( - saved_again["ok"], - true, + saved_again["ok"], true, "normal save: {}", saved_again["error"] ); @@ -1242,10 +1372,8 @@ mod tests { #[test] fn save_then_open_round_trips() { let mut app = OpenCADStudio::new_for_test(); - let path = std::env::temp_dir().join(format!( - "ocs_automation_test_{}.dxf", - std::process::id() - )); + let path = + std::env::temp_dir().join(format!("ocs_automation_test_{}.dxf", std::process::id())); let _ = std::fs::remove_file(&path); let p = path.to_string_lossy().replace('\\', "\\\\"); app.automation_op(r#"{"op":"new"}"#); @@ -1275,7 +1403,7 @@ mod tests { 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); @@ -1295,8 +1423,14 @@ mod tests { // 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())); + 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))); diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index b2ff7ea7e..4fd6ef76e 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -2,7 +2,7 @@ use super::{Message, OpenCADStudio}; use crate::command::{InputKind, StepInput}; use iced::Task; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::{collections::VecDeque, sync::OnceLock}; #[cfg(not(target_arch = "wasm32"))] mod transport; @@ -361,7 +361,7 @@ impl OpenCADStudio { "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, "operation":self.control.pending.as_ref().map(|p| &p.id), - "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -377,6 +377,8 @@ impl OpenCADStudio { | "properties" | "measure" | "query" + | "records" + | "capabilities" | "entities" | "layers" | "header" @@ -449,7 +451,9 @@ impl OpenCADStudio { return ( failure( "unknown_command", - format!("Unknown command {requested}; call commands without name to list commands"), + format!( + "Unknown command {requested}; call commands without name to list commands" + ), ), Task::none(), ); @@ -511,7 +515,7 @@ impl OpenCADStudio { "Supply a unique request_id (maximum 128 bytes)", ), Task::none(), - ) + ); } }; if let Some((_, old, result)) = self.control.completed.iter().find(|(i, _, _)| i == &id) @@ -773,6 +777,7 @@ impl OpenCADStudio { Task::none() } "property" => self.control_set_property(req)?, + "set_properties" => self.control_set_record_properties(req)?, "action" => self.control_ui_action(req)?, #[cfg(not(target_arch = "wasm32"))] "save" => { @@ -814,6 +819,12 @@ impl OpenCADStudio { }) } + pub(super) fn set_control_result(&mut self, value: Value) { + if let Some(operation) = self.control.pending.as_mut() { + operation.result = value; + } + } + pub(super) fn control_track(&mut self, task: Task) -> Task { if task.units() == 0 { return task; @@ -1101,10 +1112,12 @@ mod tests { ); let started = request(&mut app, json!({"op":"start","cmd":"LINE"})); assert_eq!(started["status"], "waiting_input"); - assert!(started["state"]["command"]["accepts"] - .as_array() - .unwrap() - .contains(&json!("point"))); + assert!( + started["state"]["command"]["accepts"] + .as_array() + .unwrap() + .contains(&json!("point")) + ); assert_eq!( started["state"]["command"]["input_example"]["kind"], "point" @@ -1129,10 +1142,12 @@ mod tests { ); assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1); request(&mut app, json!({"op":"select","type":"LINE"})); - assert!(!app.control_properties()["sections"] - .as_array() - .unwrap() - .is_empty()); + assert!( + !app.control_properties()["sections"] + .as_array() + .unwrap() + .is_empty() + ); let changed = request(&mut app, json!({"op":"property","field":"color","value":1})); assert_eq!(changed["ok"], true, "{changed}"); assert_eq!( diff --git a/src/app/mod.rs b/src/app/mod.rs index c9dbd3cbf..fe15f1ebb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,21 +9,22 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; +#[cfg(not(target_arch = "wasm32"))] +mod doc_api; mod document; pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; mod history; -#[cfg(not(target_arch = "wasm32"))] -mod doc_api; mod layers; mod model_ops; mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; -mod properties; mod presspull_ops; +mod properties; mod recent; +mod record_api; pub(crate) mod settings; mod shortcuts; mod startup; @@ -225,7 +226,10 @@ impl From<&QSelectState> for QSelectSettings { Self { scope: state.scope, type_filter: state.type_filter.clone(), - property_field: state.property.as_ref().map(|property| property.field.clone()), + property_field: state + .property + .as_ref() + .map(|property| property.field.clone()), operator: state.operator, value: state.value.clone(), mode: state.mode, @@ -256,12 +260,12 @@ pub(crate) enum FindMatchKey { } use crate::snap::Snapper; use crate::ui::{CommandLine, Ribbon, StatusBar}; -use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::CadDocument; +use acadrust::types::{Color as AcadColor, LineWeight}; use iced::time::Instant; use iced::window; -use iced::{mouse, Point, Task, Theme}; +use iced::{Point, Task, Theme, mouse}; use std::sync::Arc; pub(super) const POLY_START_DELAY_MS: u128 = 150; @@ -318,9 +322,7 @@ struct AddSelectedRestore { } /// Which Start-page section a narrow (tabbed) Start page is showing. -#[derive( - Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize, -)] +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize)] pub enum StartSection { Recent, Videos, @@ -780,15 +782,13 @@ pub(super) struct OpenCADStudio { /// Live filter for installed and available plugin cards. plugin_search_input: String, /// Installable release tags fetched per linked repo (for the dropdown). - repo_release_tags: - rustc_hash::FxHashMap>, + repo_release_tags: rustc_hash::FxHashMap>, /// The release tag currently selected per linked repo. repo_selected_tag: rustc_hash::FxHashMap, /// Repository currently shown in the Plugin Manager detail pane. selected_plugin_repo: Option, /// Parsed GitHub README content or the last fetch error, cached per repo. - plugin_readmes: - rustc_hash::FxHashMap>, + plugin_readmes: rustc_hash::FxHashMap>, /// README requests in flight, used to render a deterministic loading state. plugin_readme_loading: rustc_hash::FxHashSet, /// Last marketplace status / error line shown in the Plugin Manager. @@ -1098,8 +1098,7 @@ pub(super) struct OpenCADStudio { save_job_serial: u64, /// Destination leases held while Save As workers are active. #[cfg(not(target_arch = "wasm32"))] - pending_save_leases: - std::collections::HashMap, + pending_save_leases: std::collections::HashMap, /// Locked-file failure currently shown in the recovery dialog. #[cfg(not(target_arch = "wasm32"))] pending_save_failure: Option, @@ -1448,7 +1447,8 @@ impl ClipboardDeps { if !objects.is_empty() { let mut annotation_scales = Vec::new(); for (_, object) in &objects { - let acadrust::objects::ObjectType::ObjectContextData(context) = object else { + let acadrust::objects::ObjectType::ObjectContextData(context) = object + else { continue; }; if annotation_scales @@ -1618,9 +1618,7 @@ impl ClipboardDeps { fn snapshot_block(doc: &acadrust::CadDocument, name: &str) -> Option { use acadrust::EntityType; let br = doc.block_records.get(name)?; - if name.starts_with("*Model_Space") - || name.starts_with("*Paper_Space") - || br.flags.is_xref + if name.starts_with("*Model_Space") || name.starts_with("*Paper_Space") || br.flags.is_xref { return None; } @@ -1835,10 +1833,7 @@ pub enum Message { /// Register a font already held by the shared web store with the UI renderer. ApplyWebFont(crate::scene::text::web_font::Script), /// Completion of the UI renderer's runtime font registration. - WebUiFontLoaded( - crate::scene::text::web_font::Script, - Result<(), String>, - ), + WebUiFontLoaded(crate::scene::text::web_font::Script, Result<(), String>), /// Ctrl+V. Routed by `update` into an open text editor, the drawing-object /// clipboard, or the system text clipboard. PasteShortcut, @@ -1906,10 +1901,13 @@ pub enum Message { WebFileOpened(u64, crate::io::WebOpenOutcome), #[cfg(target_arch = "wasm32")] WebFileCached(u64, crate::io::WebOpenOutcome, Result<(), String>), - FileOpened(u64, Result< - (String, PathBuf, CadDocument, crate::scene::DerivedCaches), - crate::io::OpenLoadError, - >), + FileOpened( + u64, + Result< + (String, PathBuf, CadDocument, crate::scene::DerivedCaches), + crate::io::OpenLoadError, + >, + ), RecoveryClose, RecoveryAttempt, RecoveryDecline, @@ -2150,9 +2148,16 @@ pub enum Message { }, /// A widget captured Up/Down; resolve it only if the command input owns /// keyboard focus. - CommandLineArrowProbe { direction: ArrowKey, extend_selection: bool }, + CommandLineArrowProbe { + direction: ArrowKey, + extend_selection: bool, + }, /// Result of the command-input focus query for a captured Up/Down key. - CommandLineArrowResolved { direction: ArrowKey, focused: bool, extend_selection: bool }, + CommandLineArrowResolved { + direction: ArrowKey, + focused: bool, + extend_selection: bool, + }, /// Toggle the dropdown listing the full command-line history. CommandHistoryToggle, /// Grab/move/release the expanded history panel's top resize edge. @@ -2303,9 +2308,7 @@ pub enum Message { epoch: u64, source: usize, wires: std::sync::Weak>, - index: std::sync::Arc< - crate::scene::pick::interaction_index::InteractionIndex, - >, + index: std::sync::Arc, build_ms: f64, }, WindowResized(f32, f32), @@ -2559,7 +2562,10 @@ pub enum Message { PropEditChoiceToggle, /// User is typing in a block-attribute value field (live buffer update), /// keyed by the attribute tag. - PropAttrInput { tag: String, value: String }, + PropAttrInput { + tag: String, + value: String, + }, /// User committed a block-attribute value edit (Enter pressed). PropAttrCommit(String), /// Reports the currently keyboard-focused widget (if any) after a @@ -2585,7 +2591,10 @@ pub enum Message { PropColorFieldToggle(String), /// User picked a colour for a generic per-field colour row (hatch gradient /// `Color 1` / `Color 2`), routed by the field name. - PropColorFieldChanged { field: String, color: AcadColor }, + PropColorFieldChanged { + field: String, + color: AcadColor, + }, /// Collapse the inline color picker dropdown. Fired when another /// properties-panel dropdown (a combo_box) opens, so at most one panel /// dropdown is open at a time and they can't overlap. (#235) @@ -2697,7 +2706,10 @@ pub enum Message { /// Select the row the Text Options / Properties tabs act on. AttrEditorSelect(usize), /// Live edit of the attribute value at row `idx` (Attribute tab). - AttrEditorInput { idx: usize, value: String }, + AttrEditorInput { + idx: usize, + value: String, + }, // Text Options — all act on the selected row: AttrEditorTextStyle(String), AttrEditorJustify(String), @@ -2752,9 +2764,7 @@ pub enum Message { /// GitHub Discussions fetched at boot for the Start page. DiscussionsFetched(Result, String>), /// Recent-file DWG preview thumbnails decoded on a background thread. - RecentThumbsLoaded( - Vec<(std::path::PathBuf, Option)>, - ), + RecentThumbsLoaded(Vec<(std::path::PathBuf, Option)>), /// Installable releases and manifest API versions fetched for `owner/repo`. PluginReleasesFetched( String, @@ -2795,7 +2805,10 @@ pub enum Message { InvertSelection, /// Keyboard modifier state changed — tracks whether Shift is held so the /// pick path can do subtractive (Shift+click) selection. - SetModifiers { shift: bool, ctrl: bool }, + SetModifiers { + shift: bool, + ctrl: bool, + }, // ── In-place MText editor ─────────────────────────────────────────── /// Text-area edit action from the multi-line editor widget. MTextEdit(iced::widget::text_editor::Action), @@ -3265,10 +3278,7 @@ impl OpenCADStudio { .iter() .filter_map(|v| { let bytes = v.thumb.clone()?; - Some(( - v.id.clone(), - iced::widget::image::Handle::from_bytes(bytes), - )) + Some((v.id.clone(), iced::widget::image::Handle::from_bytes(bytes))) }) .collect(); self.videos = videos; @@ -3782,8 +3792,7 @@ impl OpenCADStudio { let state = Self::new(); let (id, open_task) = window::open(window::Settings { maximized: true, - icon: build_window_icon() - .and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()), + icon: build_window_icon().and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()), exit_on_close_request: false, // A Wayland compositor has no StartupWMClass to go on: it resolves a // window's dock icon by matching the window's app_id against the @@ -3919,24 +3928,17 @@ impl OpenCADStudio { let mut s = Self::new(); s.queue_startup_prompts(); let focus = s.focus_cmd_input(); - let primary_font = crate::scene::text::web_font::preload_language( - &crate::i18n::active_language_tag(), - ); + let primary_font = + crate::scene::text::web_font::preload_language(&crate::i18n::active_language_tag()); let fonts = Task::batch([ Task::done(Message::PollWebFonts), Task::done(Message::ApplyWebFont(primary_font)), ]); // Web can't reach the Patreon API directly (CORS); fetch the CI-built // supporters.json served on the same origin instead. - let patrons = Task::perform( - crate::patreon::fetch_patrons_web(), - Message::PatronsFetched, - ); + let patrons = Task::perform(crate::patreon::fetch_patrons_web(), Message::PatronsFetched); s.videos_loading = true; - let videos = Task::perform( - crate::videos::fetch_playlist_web(), - Message::VideosFetched, - ); + let videos = Task::perform(crate::videos::fetch_playlist_web(), Message::VideosFetched); s.discussions_loading = true; let discussions = Task::perform( crate::discussions::fetch_discussions_web(), @@ -3969,7 +3971,12 @@ pub fn run() -> iced::Result { if let Some(tab) = state.tabs.get(state.active_tab) { let dot = if tab.dirty { "● " } else { "" }; let name = tab.tab_display_name(); - format!("{}Open CAD Studio {} - {}", dot, env!("OCS_APP_VERSION"), name) + format!( + "{}Open CAD Studio {} - {}", + dot, + env!("OCS_APP_VERSION"), + name + ) } else { concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() } @@ -4003,7 +4010,9 @@ pub fn run_web() -> iced::Result { OpenCADStudio::view_main, ) .subscription(OpenCADStudio::subscription) - .title(|_state: &OpenCADStudio| concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()) + .title(|_state: &OpenCADStudio| { + concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() + }) .theme(|state: &OpenCADStudio| state.active_theme.clone()) .backend(iced::Backend::Hardware(iced::backend::Api::OpenGL)) .font(iced_aw::ICED_AW_FONT_BYTES) diff --git a/src/app/record_api.rs b/src/app/record_api.rs new file mode 100644 index 000000000..c3f77fbac --- /dev/null +++ b/src/app/record_api.rs @@ -0,0 +1,1027 @@ +use acadrust::tables::{Table, TableEntry}; +use iced::Task; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Map, Value, json}; + +use super::{Message, OpenCADStudio}; + +const COLLECTIONS: &[(&str, bool)] = &[ + ("entities", true), + ("objects", true), + ("layers", true), + ("line_types", true), + ("text_styles", true), + ("block_records", true), + ("dim_styles", true), + ("app_ids", true), + ("views", true), + ("vports", true), + ("ucss", true), + ("vx_table", true), + ("header", true), + ("summary_info", true), + ("classes", false), + ("block_visibility", false), + ("context_scales", false), + ("block_representations", false), + ("fields", false), + ("dgn_line_style_definitions", false), + ("dgn_line_style_components", false), + ("vx_control_entries", false), + ("section_view_style", false), + ("view_rep_references", false), + ("section_view_representations", false), + ("notifications", false), + ("preview", false), + ("document", false), +]; + +fn failure(code: &str, message: impl Into) -> Value { + json!({"ok":false,"status":"failed","code":code,"error":message.into()}) +} + +fn json_value(value: &impl Serialize) -> Result { + serde_json::to_value(value).map_err(|error| error.to_string()) +} + +fn enum_parts(value: &impl Serialize) -> Result<(String, Value), String> { + let Value::Object(wrapper) = json_value(value)? else { + return Err("record type is not represented by an object".into()); + }; + if wrapper.len() != 1 { + return Err("record type has an invalid representation".into()); + } + Ok(wrapper.into_iter().next().expect("one entry")) +} + +fn decode_enum(kind: &str, properties: Value) -> Result { + let mut wrapper = Map::new(); + wrapper.insert(kind.to_string(), properties); + serde_json::from_value(Value::Object(wrapper)).map_err(|error| error.to_string()) +} + +fn handle_text(handle: acadrust::Handle) -> String { + format!("{:X}", handle.value()) +} + +fn without_hex_prefix(value: &str) -> &str { + value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value) +} + +fn record( + collection: &str, + kind: &str, + handle: Option, + name: Option<&str>, + mutable: bool, + properties: Value, +) -> Value { + let mut value = json!({ + "collection":collection, + "type":kind, + "mutable":mutable, + "properties":properties, + }); + let object = value.as_object_mut().expect("record object"); + if let Some(handle) = handle { + object.insert("handle".into(), json!(handle_text(handle))); + } + if let Some(name) = name { + object.insert("name".into(), json!(name)); + } + value +} + +fn entity_record(entity: &acadrust::EntityType) -> Result { + let (kind, properties) = enum_parts(entity)?; + let mut value = record( + "entities", + &kind, + Some(entity.common().handle), + None, + true, + properties, + ); + let object = value.as_object_mut().expect("record object"); + object.insert( + "display_type".into(), + json!(crate::entities::names::ui_name(entity)), + ); + object.insert( + "record_type".into(), + json!(crate::entities::names::dxf_name(entity)), + ); + Ok(value) +} + +fn object_record( + handle: acadrust::Handle, + object: &acadrust::objects::ObjectType, +) -> Result { + let (kind, properties) = enum_parts(object)?; + Ok(record( + "objects", + &kind, + Some(handle), + None, + true, + properties, + )) +} + +fn table_records( + collection: &str, + kind: &str, + table: &Table, +) -> Result, String> { + table + .iter() + .map(|entry| { + Ok(record( + collection, + kind, + Some(entry.handle()), + Some(entry.name()), + true, + json_value(entry)?, + )) + }) + .collect() +} + +fn map_records( + collection: &str, + kind: &str, + values: &std::collections::HashMap, +) -> Result, String> { + let mut entries: Vec<_> = values.iter().collect(); + entries.sort_by_key(|(handle, _)| handle.value()); + entries + .into_iter() + .map(|(handle, value)| { + Ok(record( + collection, + kind, + Some(*handle), + None, + false, + json_value(value)?, + )) + }) + .collect() +} + +fn collection_records( + document: &acadrust::CadDocument, + collection: &str, +) -> Result, String> { + match collection { + "entities" => document.entities().map(entity_record).collect(), + "objects" => { + let mut objects: Vec<_> = document.objects.iter().collect(); + objects.sort_by_key(|(handle, _)| handle.value()); + objects + .into_iter() + .map(|(handle, object)| object_record(*handle, object)) + .collect() + } + "layers" => table_records("layers", "Layer", &document.layers), + "line_types" => table_records("line_types", "LineType", &document.line_types), + "text_styles" => table_records("text_styles", "TextStyle", &document.text_styles), + "block_records" => table_records("block_records", "BlockRecord", &document.block_records), + "dim_styles" => table_records("dim_styles", "DimStyle", &document.dim_styles), + "app_ids" => table_records("app_ids", "AppId", &document.app_ids), + "views" => table_records("views", "View", &document.views), + "vports" => table_records("vports", "VPort", &document.vports), + "ucss" => table_records("ucss", "Ucs", &document.ucss), + "vx_table" => table_records("vx_table", "VxTableRecord", &document.vx_table), + "header" => Ok(vec![record( + "header", + "HeaderVariables", + None, + Some("header"), + true, + json_value(&document.header)?, + )]), + "summary_info" => Ok(vec![record( + "summary_info", + "SummaryInfo", + None, + Some("summary_info"), + true, + json_value(&document.summary_info)?, + )]), + "classes" => document + .classes + .iter() + .map(|value| { + Ok(record( + "classes", + "Class", + None, + Some(&value.dxf_name), + false, + json_value(value)?, + )) + }) + .collect(), + "block_visibility" => map_records( + "block_visibility", + "BlockVisibilityParameter", + &document.block_visibility_params, + ), + "context_scales" => map_records("context_scales", "Handle", &document.context_scales), + "block_representations" => map_records( + "block_representations", + "Handle", + &document.block_representations, + ), + "fields" => map_records("fields", "Field", &document.fields), + "dgn_line_style_definitions" => map_records( + "dgn_line_style_definitions", + "LineStyleDefinition", + &document.dgn_ls_definitions, + ), + "dgn_line_style_components" => map_records( + "dgn_line_style_components", + "LineStyleComponent", + &document.dgn_ls_components, + ), + "vx_control_entries" => Ok(document + .vx_control_entries + .iter() + .map(|handle| { + record( + "vx_control_entries", + "Handle", + Some(*handle), + None, + false, + json!({}), + ) + }) + .collect()), + "section_view_style" => Ok(document + .section_view_style + .as_ref() + .map(|value| { + record( + "section_view_style", + "SectionViewStyle", + None, + Some("section_view_style"), + false, + json_value(value).unwrap_or(Value::Null), + ) + }) + .into_iter() + .collect()), + "view_rep_references" => map_records( + "view_rep_references", + "HandleReferences", + &document.view_rep_refs, + ), + "section_view_representations" => Ok(document + .section_view_reps + .iter() + .map(|handle| { + record( + "section_view_representations", + "Handle", + Some(*handle), + None, + false, + json!({}), + ) + }) + .collect()), + "notifications" => Ok(vec![record( + "notifications", + "Notifications", + None, + Some("notifications"), + false, + json_value(&document.notifications)?, + )]), + "preview" => Ok(document + .preview + .as_ref() + .map(|value| { + record( + "preview", + "Preview", + None, + Some("preview"), + false, + json_value(value).unwrap_or(Value::Null), + ) + }) + .into_iter() + .collect()), + "document" => Ok(vec![record( + "document", + "Document", + None, + Some("document"), + false, + json!({ + "version":document.version, + "maintenance_version":document.maintenance_version, + "source_path":document.source_path, + "source_version":document.dwg_source_version, + }), + )]), + _ => Err(format!("unknown record collection: {collection}")), + } +} + +fn collection_count(document: &acadrust::CadDocument, collection: &str) -> usize { + match collection { + "entities" => document.entities().count(), + "objects" => document.objects.len(), + "layers" => document.layers.len(), + "line_types" => document.line_types.len(), + "text_styles" => document.text_styles.len(), + "block_records" => document.block_records.len(), + "dim_styles" => document.dim_styles.len(), + "app_ids" => document.app_ids.len(), + "views" => document.views.len(), + "vports" => document.vports.len(), + "ucss" => document.ucss.len(), + "vx_table" => document.vx_table.len(), + "classes" => document.classes.len(), + "block_visibility" => document.block_visibility_params.len(), + "context_scales" => document.context_scales.len(), + "block_representations" => document.block_representations.len(), + "fields" => document.fields.len(), + "dgn_line_style_definitions" => document.dgn_ls_definitions.len(), + "dgn_line_style_components" => document.dgn_ls_components.len(), + "vx_control_entries" => document.vx_control_entries.len(), + "section_view_style" => usize::from(document.section_view_style.is_some()), + "view_rep_references" => document.view_rep_refs.len(), + "section_view_representations" => document.section_view_reps.len(), + "preview" => usize::from(document.preview.is_some()), + _ => 1, + } +} + +fn compare( + actual: Option<&Value>, + operator: &str, + expected: Option<&Value>, +) -> Result { + let exists = actual.is_some(); + if operator == "exists" { + return Ok(exists); + } + if operator == "not_exists" { + return Ok(!exists); + } + let Some(actual) = actual else { + return Ok(false); + }; + let expected = expected.ok_or_else(|| format!("filter operator {operator} requires value"))?; + Ok(match operator { + "eq" => actual == expected, + "ne" => actual != expected, + "lt" | "lte" | "gt" | "gte" => { + let left = actual + .as_f64() + .ok_or_else(|| format!("filter operator {operator} requires numeric values"))?; + let right = expected + .as_f64() + .ok_or_else(|| format!("filter operator {operator} requires numeric values"))?; + match operator { + "lt" => left < right, + "lte" => left <= right, + "gt" => left > right, + _ => left >= right, + } + } + "contains" => match (actual, expected) { + (Value::String(left), Value::String(right)) => left.contains(right), + (Value::Array(values), expected) => values.contains(expected), + _ => return Err("contains requires a string/string or array/value pair".into()), + }, + "starts_with" => actual + .as_str() + .zip(expected.as_str()) + .is_some_and(|(left, right)| left.starts_with(right)), + "ends_with" => actual + .as_str() + .zip(expected.as_str()) + .is_some_and(|(left, right)| left.ends_with(right)), + "in" => expected + .as_array() + .is_some_and(|values| values.contains(actual)), + _ => return Err(format!("unknown filter operator: {operator}")), + }) +} + +fn matches_filters(record: &Value, request: &Value) -> Result { + if request["handle"].as_str().is_some_and(|requested| { + !record["handle"] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(without_hex_prefix(requested))) + }) { + return Ok(false); + } + if let Some(handles) = request["handles"].as_array() { + let Some(actual) = record["handle"].as_str() else { + return Ok(false); + }; + if !handles + .iter() + .filter_map(Value::as_str) + .any(|requested| actual.eq_ignore_ascii_case(without_hex_prefix(requested))) + { + return Ok(false); + } + } + if request["type"].as_str().is_some_and(|requested| { + !["type", "display_type", "record_type"].iter().any(|key| { + record[*key] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(requested)) + }) + }) { + return Ok(false); + } + if request["name"].as_str().is_some_and(|requested| { + !record["name"] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(requested)) + }) { + return Ok(false); + } + if let Some(filters) = request["where"].as_array() { + for filter in filters { + let path = filter["path"] + .as_str() + .ok_or_else(|| "each filter requires a JSON Pointer path".to_string())?; + if !path.is_empty() && !path.starts_with('/') { + return Err(format!("invalid JSON Pointer: {path}")); + } + let actual = if path.is_empty() { + Some(&record["properties"]) + } else { + record["properties"].pointer(path) + }; + let operator = filter["op"].as_str().unwrap_or("eq"); + if !compare(actual, operator, filter.get("value"))? { + return Ok(false); + } + } + } + Ok(true) +} + +fn project_paths(mut record: Value, paths: Option<&Vec>) -> Result { + let Some(paths) = paths else { + return Ok(record); + }; + let mut projected = Map::new(); + for path in paths { + let path = path + .as_str() + .ok_or_else(|| "paths must contain JSON Pointer strings".to_string())?; + if !path.is_empty() && !path.starts_with('/') { + return Err(format!("invalid JSON Pointer: {path}")); + } + let value = if path.is_empty() { + Some(&record["properties"]) + } else { + record["properties"].pointer(path) + }; + projected.insert(path.to_string(), value.cloned().unwrap_or(Value::Null)); + } + let object = record.as_object_mut().expect("record object"); + object.remove("properties"); + object.insert("values".into(), Value::Object(projected)); + Ok(record) +} + +fn requested_handle(request: &Value) -> Result { + request["handle"] + .as_str() + .and_then(|value| u64::from_str_radix(without_hex_prefix(value), 16).ok()) + .map(acadrust::Handle::new) + .ok_or_else(|| "set_properties requires a hexadecimal handle".to_string()) +} + +fn apply_updates( + properties: &mut Value, + request: &Value, + collection: &str, +) -> Result, String> { + let updates = request["updates"] + .as_array() + .ok_or_else(|| "set_properties requires a non-empty updates array".to_string())?; + if updates.is_empty() { + return Err("set_properties requires a non-empty updates array".into()); + } + let mut paths = Vec::with_capacity(updates.len()); + for update in updates { + let path = update["path"] + .as_str() + .ok_or_else(|| "each update requires a JSON Pointer path".to_string())?; + if path.is_empty() || !path.starts_with('/') { + return Err("updates require a non-empty JSON Pointer path".into()); + } + let identity = match collection { + "entities" => matches!(path, "/common/handle" | "/common/owner_handle"), + "objects" => matches!(path, "/handle" | "/common/handle"), + "header" => path == "/handle_seed", + "summary_info" => false, + _ => matches!(path, "/handle" | "/name"), + }; + if identity { + return Err(format!("{path} is read-only identity data")); + } + let value = update + .get("value") + .ok_or_else(|| format!("update {path} requires value"))? + .clone(); + let target = properties + .pointer_mut(path) + .ok_or_else(|| format!("property does not exist: {path}"))?; + if let Some(expected) = update.get("expected") { + if target != expected { + return Err(format!("property changed before update: {path}")); + } + } + *target = value; + paths.push(path.to_string()); + } + Ok(paths) +} + +fn patched_table_entry( + table: &Table, + request: &Value, + collection: &str, +) -> Result<(T, T, Vec), String> +where + T: Clone + PartialEq + Serialize + DeserializeOwned + TableEntry, +{ + let requested = request["handle"].as_str().and_then(|value| { + u64::from_str_radix(without_hex_prefix(value), 16) + .ok() + .map(acadrust::Handle::new) + }); + let name = request["name"].as_str(); + let source = table + .iter() + .find(|entry| { + requested.is_some_and(|handle| entry.handle() == handle) + || name.is_some_and(|name| entry.name().eq_ignore_ascii_case(name)) + }) + .cloned() + .ok_or_else(|| format!("record does not exist in {collection}"))?; + let mut properties = json_value(&source)?; + let paths = apply_updates(&mut properties, request, collection)?; + let edited: T = serde_json::from_value(properties).map_err(|error| error.to_string())?; + if edited.handle() != source.handle() || edited.name() != source.name() { + return Err("record identity is read-only".into()); + } + Ok((source, edited, paths)) +} + +fn replace_table_entry(table: &mut Table, handle: acadrust::Handle, edited: T) { + let target = table + .iter_mut() + .find(|entry| entry.handle() == handle) + .expect("validated table entry"); + *target = edited; +} + +impl OpenCADStudio { + pub(super) fn record_capabilities(&self) -> Value { + let document = &self.tabs[self.active_tab].scene.document; + json!({ + "ok":true, + "api":"ocs-cad-automation", + "version":1, + "concurrency":{"document_id":true,"revision":true,"request_id":true}, + "transactions":{"undo":true,"redo":true,"atomic_property_updates":true,"batch":true}, + "geometry":{"query":true,"kernel_measurements":true,"spatial_filters":true,"interactive_commands":true}, + "records":{ + "read":"records", + "write":"set_properties", + "path":"RFC 6901 JSON Pointer relative to properties", + "filters":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"], + "collections":COLLECTIONS.iter().map(|(name, mutable)|json!({ + "name":name, + "mutable":mutable, + "count":collection_count(document, name), + })).collect::>() + }, + "editor":{"selection":true,"properties":true,"commands":true,"files":true,"capture":true,"events":true} + }) + } + + pub(super) fn record_query(&self, request: &Value) -> Value { + let document = &self.tabs[self.active_tab].scene.document; + let Some(collection) = request["collection"].as_str() else { + return self.record_capabilities(); + }; + let mut records = Vec::new(); + if collection == "all" { + for (name, _) in COLLECTIONS { + match collection_records(document, name) { + Ok(mut values) => records.append(&mut values), + Err(error) => return failure("serialization_failed", error), + } + } + } else { + match collection_records(document, collection) { + Ok(values) => records = values, + Err(error) => return failure("unknown_collection", error), + } + } + let mut matched = Vec::new(); + for record in records { + match matches_filters(&record, request) { + Ok(true) => matched.push(record), + Ok(false) => {} + Err(error) => return failure("invalid_filter", error), + } + } + let count = matched.len(); + let offset = request["offset"].as_u64().unwrap_or(0) as usize; + let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; + let mut returned = Vec::new(); + for record in matched.into_iter().skip(offset).take(limit) { + match project_paths(record, request["paths"].as_array()) { + Ok(record) => returned.push(record), + Err(error) => return failure("invalid_projection", error), + } + } + json!({ + "ok":true, + "document_id":self.tabs[self.active_tab].id, + "revision":self.tabs[self.active_tab].edit_revision, + "geometry_revision":self.tabs[self.active_tab].scene.geometry_epoch, + "collection":collection, + "count":count, + "returned":returned.len(), + "next_offset":(offset + returned.len() < count).then_some(offset + returned.len()), + "records":returned, + }) + } + + pub(super) fn control_set_record_properties( + &mut self, + request: &Value, + ) -> Result, Value> { + let collection = request["collection"] + .as_str() + .ok_or_else(|| failure("collection_required", "set_properties requires collection"))?; + if !COLLECTIONS + .iter() + .any(|(name, mutable)| *name == collection && *mutable) + { + return Err(failure( + "read_only_collection", + format!("collection is absent or read-only: {collection}"), + )); + } + let i = self.active_tab; + let changed; + let mut result_handle = None; + let mut result_name = None; + let paths; + match collection { + "entities" => { + let handle = + requested_handle(request).map_err(|error| failure("invalid_handle", error))?; + if self.tabs[i].scene.is_layer_locked(handle) { + return Err(failure("layer_locked", "entity is on a locked layer")); + } + let source = self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .ok_or_else(|| failure("record_absent", "entity does not exist"))?; + let (kind, mut properties) = + enum_parts(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let mut edited: acadrust::EntityType = decode_enum(&kind, properties) + .map_err(|error| failure("invalid_value", error))?; + if edited.common().handle != handle { + return Err(failure("identity_changed", "entity identity is read-only")); + } + edited.preserve_storage_data_from(&source); + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + if !self.tabs[i].scene.update_entity(edited) { + self.discard_last_undo_entry(i); + return Err(failure("update_failed", "entity could not be updated")); + } + self.tabs[i].dirty = true; + } + result_handle = Some(handle_text(handle)); + } + "objects" => { + let handle = + requested_handle(request).map_err(|error| failure("invalid_handle", error))?; + let source = self.tabs[i] + .scene + .document + .objects + .get(&handle) + .cloned() + .ok_or_else(|| failure("record_absent", "object does not exist"))?; + let (kind, mut properties) = + enum_parts(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let mut edited: acadrust::objects::ObjectType = decode_enum(&kind, properties) + .map_err(|error| failure("invalid_value", error))?; + edited.preserve_storage_data_from(&source); + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + self.tabs[i].scene.document.objects.insert(handle, edited); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + result_handle = Some(handle_text(handle)); + } + "header" => { + let source = self.tabs[i].scene.document.header.clone(); + let mut properties = + json_value(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let edited = + serde_json::from_value(properties).map_err(|error: serde_json::Error| { + failure("invalid_value", error.to_string()) + })?; + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + self.tabs[i].scene.document.header = edited; + self.tabs[i].adopt_active_ucs_from_header(); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + result_name = Some("header".to_string()); + } + "summary_info" => { + let source = self.tabs[i].scene.document.summary_info.clone(); + let mut properties = + json_value(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let edited = + serde_json::from_value(properties).map_err(|error: serde_json::Error| { + failure("invalid_value", error.to_string()) + })?; + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + self.tabs[i].scene.document.summary_info = edited; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + result_name = Some("summary_info".to_string()); + } + _ => { + macro_rules! patch_table { + ($field:ident) => {{ + let (source, edited, changed_paths) = patched_table_entry( + &self.tabs[i].scene.document.$field, + request, + collection, + ) + .map_err(|error| failure("invalid_update", error))?; + paths = changed_paths; + changed = edited != source; + let handle = source.handle(); + result_handle = Some(handle_text(handle)); + result_name = Some(source.name().to_string()); + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + replace_table_entry( + &mut self.tabs[i].scene.document.$field, + handle, + edited, + ); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + }}; + } + match collection { + "layers" => patch_table!(layers), + "line_types" => patch_table!(line_types), + "text_styles" => patch_table!(text_styles), + "block_records" => patch_table!(block_records), + "dim_styles" => patch_table!(dim_styles), + "app_ids" => patch_table!(app_ids), + "views" => patch_table!(views), + "vports" => patch_table!(vports), + "ucss" => patch_table!(ucss), + "vx_table" => patch_table!(vx_table), + _ => unreachable!(), + } + } + } + if changed { + self.refresh_properties(); + } + self.set_control_result(json!({ + "collection":collection, + "handle":result_handle, + "name":result_name, + "paths":paths, + "changed":changed, + })); + Ok(Task::none()) + } +} + +#[cfg(test)] +mod tests { + use acadrust::entities::{AttributeEntity, EntityType, Insert}; + use acadrust::objects::{Dictionary, ObjectType}; + use acadrust::types::{Handle, Vector3}; + use serde_json::{Value, json}; + + use super::OpenCADStudio; + + fn execute(app: &mut OpenCADStudio, mut request: Value, id: &str) -> Value { + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let object = request.as_object_mut().unwrap(); + object.insert("protocol".into(), json!(1)); + object.insert("request_id".into(), json!(id)); + object.insert("document_id".into(), state["document_id"].clone()); + object.insert("revision".into(), state["revision"].clone()); + app.automation_op(&request.to_string()) + } + + #[test] + fn records_query_and_edit_every_mutable_record_family() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0)); + insert + .attributes + .push(AttributeEntity::simple("COMPANY", "BPH")); + let entity = app.tabs[i].scene.add_entity(EntityType::Insert(insert)); + + let object_handle = Handle::new(0x500); + let mut dictionary = Dictionary::new(); + dictionary.handle = object_handle; + app.tabs[i] + .scene + .document + .objects + .insert(object_handle, ObjectType::Dictionary(dictionary)); + + let records = app.record_query(&json!({ + "collection":"entities", + "type":"Insert", + "where":[{"path":"/attributes/0/tag","value":"COMPANY"}] + })); + assert_eq!(records["count"], 1); + assert_eq!(records["records"][0]["properties"]["block_name"], "A_CPT"); + assert_eq!( + records["records"][0]["properties"]["attributes"][0]["value"], + "BPH" + ); + + let edited = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle":format!("{:X}", entity.value()), + "updates":[ + {"path":"/attributes/0/value","expected":"BPH","value":"Edited"}, + {"path":"/insert_point/x","value":20.0} + ] + }), + "edit-entity", + ); + assert_eq!(edited["status"], "completed", "{edited}"); + assert_eq!(edited["result"]["changed"], true); + let EntityType::Insert(insert) = app.tabs[i].scene.document.get_entity(entity).unwrap() + else { + panic!("expected insert") + }; + assert_eq!(insert.attributes[0].value, "Edited"); + assert_eq!(insert.insert_point.x, 20.0); + + let object_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"objects", + "handle":"500", + "updates":[{"path":"/hard_owner","value":true}] + }), + "edit-object", + ); + assert_eq!(object_edit["result"]["changed"], true, "{object_edit}"); + let Some(ObjectType::Dictionary(dictionary)) = + app.tabs[i].scene.document.objects.get(&object_handle) + else { + panic!("expected dictionary") + }; + assert!(dictionary.hard_owner); + + let layer_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"layers", + "name":"0", + "updates":[{"path":"/is_plottable","value":false}] + }), + "edit-layer", + ); + assert_eq!(layer_edit["result"]["changed"], true, "{layer_edit}"); + assert!( + !app.tabs[i] + .scene + .document + .layers + .get("0") + .unwrap() + .is_plottable + ); + + let header_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"header", + "updates":[{"path":"/point_display_size","value":7.5}] + }), + "edit-header", + ); + assert_eq!(header_edit["result"]["changed"], true, "{header_edit}"); + assert_eq!(app.tabs[i].scene.document.header.point_display_size, 7.5); + + let summary_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"summary_info", + "updates":[{"path":"/author","value":"MCP"}] + }), + "edit-summary", + ); + assert_eq!(summary_edit["result"]["changed"], true, "{summary_edit}"); + assert_eq!(app.tabs[i].scene.document.summary_info.author, "MCP"); + + let undo = execute(&mut app, json!({"op":"undo"}), "undo-summary"); + assert_eq!(undo["status"], "completed", "{undo}"); + assert!(app.tabs[i].scene.document.summary_info.author.is_empty()); + } + + #[test] + fn record_updates_reject_type_identity_and_compare_failures() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let entity = app.tabs[i] + .scene + .add_entity(EntityType::Insert(Insert::new("A", Vector3::ZERO))); + let handle = format!("{:X}", entity.value()); + + let wrong_type = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/rotation","value":"invalid"}]}), + "wrong-type", + ); + assert_eq!(wrong_type["code"], "invalid_value", "{wrong_type}"); + + let identity = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/common/handle","value":99}]}), + "identity", + ); + assert_eq!(identity["code"], "invalid_update", "{identity}"); + + let compare = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/rotation","expected":1.0,"value":2.0}]}), + "compare", + ); + assert_eq!(compare["code"], "invalid_update", "{compare}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5a8bda72d..a02cd5bcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,25 +1,26 @@ #![allow(non_snake_case)] +#![recursion_limit = "256"] pub mod app; -pub mod config; #[cfg(not(target_arch = "wasm32"))] pub mod cli; pub mod command; +pub mod config; +pub mod discussions; pub mod entities; pub mod i18n; pub mod io; #[cfg(not(target_arch = "wasm32"))] -pub(crate) mod network; -#[cfg(not(target_arch = "wasm32"))] pub mod mcp; pub mod modules; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod network; +pub mod par; pub mod patreon; -pub mod discussions; -pub mod videos; -pub mod plugin; pub mod perf; +pub mod plugin; pub mod scene; pub mod snap; -pub mod ui; -pub mod par; pub mod sys; +pub mod ui; +pub mod videos; diff --git a/src/mcp.rs b/src/mcp.rs index 0435147a2..3db397aae 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,11 +22,13 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Preserve document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", "query", + "records", + "capabilities", "entities", "layers", "header", @@ -38,12 +40,39 @@ const READ_OPS: &[&str] = &[ "operation", ]; const EXECUTE_OPS: &[&str] = &[ - "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", - "property", "action", "save", "stop", "batch", + "new", + "open", + "activate", + "run", + "start", + "input", + "cancel", + "undo", + "redo", + "select", + "property", + "set_properties", + "action", + "save", + "stop", + "batch", ]; const BATCH_STEP_OPS: &[&str] = &[ - "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", - "property", "action", "save", "stop", + "new", + "open", + "activate", + "run", + "start", + "input", + "cancel", + "undo", + "redo", + "select", + "property", + "set_properties", + "action", + "save", + "stop", ]; const MAX_BATCH_STEPS: usize = 64; @@ -671,6 +700,15 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"property","field":"color","value":1}"#, ) } + "set_properties" + if request["collection"].as_str().is_none_or(str::is_empty) + || request["updates"].as_array().is_none_or(Vec::is_empty) => + { + missing( + "collection or updates", + r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#, + ) + } "action" => match request["name"].as_str() { Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()), Some(name) => Err(format!( @@ -833,6 +871,8 @@ fn batch_step_schema() -> Value { "clear":{"type":"boolean"}, "field":{"type":"string"}, "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "collection":{"type":"string"}, + "updates":{"type":"array","minItems":1,"items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{}},"required":["path","value"],"additionalProperties":false}}, "name":{"type":"string","enum":crate::app::automation_action_names()} }, "required":["op"], @@ -873,6 +913,8 @@ fn execute_request_schema() -> Value { "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, "field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."}, "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "collection":{"type":"string","description":"Record collection returned by ocs_read records."}, + "updates":{"type":"array","minItems":1,"description":"Atomic, type-checked property replacements. Paths are RFC 6901 JSON Pointers relative to record.properties.","items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{"description":"Optional compare-and-set value."}},"required":["path","value"],"additionalProperties":false}}, "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."}, "steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()} }, @@ -898,6 +940,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"redo"}}}, {"properties":{"op":{"const":"select"}}}, {"properties":{"op":{"const":"property"}},"required":["field","value"]}, + {"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, {"properties":{"op":{"const":"save"}}}, {"properties":{"op":{"const":"stop"}}}, @@ -943,8 +986,8 @@ fn tool_definitions() -> Value { }, { "name":"ocs_read", - "description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "description":"Discover capabilities or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all, or omit to discover collections."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, @@ -1272,13 +1315,17 @@ mod tests { ); assert_eq!(tools[0]["annotations"]["readOnlyHint"], false); for tool in [&tools[1], &tools[2], &tools[3]] { - assert!(tool["inputSchema"]["properties"] - .get("session_id") - .is_none()); - assert!(tool["inputSchema"]["required"] - .as_array() - .unwrap() - .contains(&json!("ocs_session_id"))); + assert!( + tool["inputSchema"]["properties"] + .get("session_id") + .is_none() + ); + assert!( + tool["inputSchema"]["required"] + .as_array() + .unwrap() + .contains(&json!("ocs_session_id")) + ); } assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["required"], @@ -1307,6 +1354,31 @@ mod tests { tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], "LINE 0,0 10,10" ); + assert!(READ_OPS.contains(&"capabilities")); + assert!(READ_OPS.contains(&"records")); + assert!(EXECUTE_OPS.contains(&"set_properties")); + assert_eq!( + tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"] + ["op"]["enum"], + json!([ + "eq", + "ne", + "lt", + "lte", + "gt", + "gte", + "contains", + "starts_with", + "ends_with", + "in", + "exists", + "not_exists" + ]) + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["updates"]["items"]["required"], + json!(["path", "value"]) + ); assert!(tools[0].get("outputSchema").is_some()); assert!(tools[1].get("outputSchema").is_some()); assert!(tools[2].get("outputSchema").is_some()); From 4ddd19f6e8ce4b96cf4a7718a4dd01d92a8780f6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 9 Sep 2026 01:01:44 +0300 Subject: [PATCH 71/71] feat(mcp): describe complete CAD record schemas --- crates/ocs_plugin_api/ARCHITECTURE.md | 2 +- crates/ocs_plugin_api/README.md | 2 +- crates/ocs_plugin_api/build.rs | 409 +++++++++- crates/ocs_plugin_api/src/type_registry.rs | 56 +- docs/automation/README.md | 15 + docs/automation/mcp_smoke.py | 1 + src/app/automation.rs | 1 + src/app/control/mod.rs | 3 +- src/app/record_api.rs | 864 ++++++++++++++++++++- src/mcp.rs | 8 +- 10 files changed, 1329 insertions(+), 32 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 1a8e49ebe..61bc16b05 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -219,7 +219,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p The crate embeds two JSON blobs at build time: -- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for every editor record root and its dependent types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). - **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). Both are accessible without enabling the `host` feature. diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index f15d41d04..e1d29878d 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -243,7 +243,7 @@ session belongs to. `ocs_plugin_api` embeds two JSON blobs at build time: - **Type registry** — a stable, language-binding-friendly schema generated by - tracing a curated allow-list of `acadrust` types with `serde-reflection`. + tracing every editor record root and its dependent types with `serde-reflection`. - **Version info** — `ocs_version`, `ocs_plugin_api_version`, `acadrust_version`, `api_version`, etc. diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index fd1dc38b9..d8ed9a7b0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -34,11 +34,42 @@ fn generate_type_registry(out_dir: &Path) { let mut samples = Samples::new(); add_enum_samples(&mut tracer, &mut samples); - // Curated allow-list of acadrust types. We intentionally exclude - // EntityType here to avoid pulling in all 41+ entity variants and any - // custom-serializer edge cases. + // Trace the complete record roots used by the editor and MCP API. Nested + // entity/object variants and their enums are added to the same registry. type TraceFn = fn(&mut Tracer, &Samples); let types: Vec<(&str, TraceFn)> = vec![ + ("EntityType", trace::), + ("ObjectType", trace::), + ( + "HeaderVariables", + trace::, + ), + ("SummaryInfo", trace::), + ("LineType", trace::), + ("TextStyle", trace::), + ("BlockRecord", trace::), + ("DimStyle", trace::), + ("AppId", trace::), + ("View", trace::), + ("VPort", trace::), + ("Ucs", trace::), + ("VxTableRecord", trace::), + ("DxfClass", trace::), + ( + "BlockVisibilityParameter", + trace::, + ), + ("FieldDef", trace::), + ( + "DgnLsDefinition", + trace::, + ), + ("DgnLsComponent", trace::), + ( + "NotificationCollection", + trace::, + ), + ("Preview", trace::), ("Point", trace::), ("Line", trace::), ("Circle", trace::), @@ -93,10 +124,366 @@ fn generate_type_registry(out_dir: &Path) { ); } + type TraceSimpleFn = fn(&mut Tracer); + let enums: Vec<(&str, TraceSimpleFn)> = vec![ + ( + "AssocAnnotationKind", + trace_simple::, + ), + ( + "AssocConstraintNodeData", + trace_simple::, + ), + ( + "AssocEvalValue", + trace_simple::, + ), + ( + "AssocSubcurveKind", + trace_simple::, + ), + ( + "AssocSurfaceActionKind", + trace_simple::, + ), + ( + "AssocViewObjectActionParamKind", + trace_simple::, + ), + ( + "AcisVersion", + trace_simple::, + ), + ( + "AssociativeData", + trace_simple::, + ), + ( + "AttachmentPointType", + trace_simple::, + ), + ( + "BlockContentConnectionType", + trace_simple::, + ), + ( + "BlockEvalValue", + trace_simple::, + ), + ("BorderType", trace_simple::), + ( + "BoundaryEdge", + trace_simple::, + ), + ( + "BreakFlowDirection", + trace_simple::, + ), + ( + "CellAlignment", + trace_simple::, + ), + ( + "CellStyleType", + trace_simple::, + ), + ("CellType", trace_simple::), + ( + "CellValueType", + trace_simple::, + ), + ( + "ClassObjectData", + trace_simple::, + ), + ("ClipMode", trace_simple::), + ("ClipType", trace_simple::), + ( + "CompoundEntry", + trace_simple::, + ), + ( + "CompoundPropertyValue", + trace_simple::, + ), + ( + "CompoundStreamContent", + trace_simple::, + ), + ( + "DataObjectData", + trace_simple::, + ), + ( + "DgnLineStyleData", + trace_simple::, + ), + ( + "DgnLsComponentData", + trace_simple::, + ), + ( + "DgnLsComponentType", + trace_simple::, + ), + ( + "DgnLsPhaseMode", + trace_simple::, + ), + ("DimSubtype", trace_simple::), + ("Dimension", trace_simple::), + ( + "DimensionType", + trace_simple::, + ), + ( + "DynamicBlockData", + trace_simple::, + ), + ( + "EmbeddedEntity", + trace_simple::, + ), + ( + "ExtendedEntityData", + trace_simple::, + ), + ( + "FlowDirectionType", + trace_simple::, + ), + ( + "HatchPatternType", + trace_simple::, + ), + ( + "HatchStyleType", + trace_simple::, + ), + ( + "HelixConstraint", + trace_simple::, + ), + ( + "HooklineDirection", + trace_simple::, + ), + ( + "HorizontalAlignment", + trace_simple::, + ), + ( + "LeaderContentType", + trace_simple::, + ), + ( + "LeaderCreationType", + trace_simple::, + ), + ( + "LeaderDrawOrderType", + trace_simple::, + ), + ( + "LeaderPathType", + trace_simple::, + ), + ( + "LineTypeComplexContent", + trace_simple::, + ), + ( + "LegacyEntityData", + trace_simple::, + ), + ( + "MLineJustification", + trace_simple::, + ), + ("MTextFlag", trace_simple::), + ( + "MaterialProceduralValue", + trace_simple::, + ), + ( + "MultiLeaderDrawOrderType", + trace_simple::, + ), + ( + "MultiLeaderPathType", + trace_simple::, + ), + ( + "ObjectContextKind", + trace_simple::, + ), + ( + "NotificationType", + trace_simple::, + ), + ( + "OleFrameEnvelope", + trace_simple::, + ), + ( + "OleObjectType", + trace_simple::, + ), + ( + "PlotPaperUnits", + trace_simple::, + ), + ( + "PlotRotation", + trace_simple::, + ), + ("PlotType", trace_simple::), + ( + "PreviewFormat", + trace_simple::, + ), + ( + "PolyfaceSmoothType", + trace_simple::, + ), + ( + "ProxyPayloadEncoding", + trace_simple::, + ), + ( + "ResolutionUnit", + trace_simple::, + ), + ("ScaledType", trace_simple::), + ( + "SemanticPropertyValue", + trace_simple::, + ), + ( + "ShadePlotMode", + trace_simple::, + ), + ( + "ShadePlotResolutionLevel", + trace_simple::, + ), + ( + "SolidHistoryOperation", + trace_simple::, + ), + ( + "SurfaceData", + trace_simple::, + ), + ( + "SurfaceKind", + trace_simple::, + ), + ( + "SurfaceSmoothType", + trace_simple::, + ), + ( + "TableBorderType", + trace_simple::, + ), + ( + "TableCellContentType", + trace_simple::, + ), + ( + "TableFlowDirection", + trace_simple::, + ), + ( + "TextAlignmentType", + trace_simple::, + ), + ( + "TextAngleType", + trace_simple::, + ), + ( + "TextAttachmentDirectionType", + trace_simple::, + ), + ( + "TextAttachmentPointType", + trace_simple::, + ), + ( + "TextAttachmentType", + trace_simple::, + ), + ( + "TextHorizontalAlignment", + trace_simple::, + ), + ( + "TextVerticalAlignment", + trace_simple::, + ), + ( + "UnderlayType", + trace_simple::, + ), + ( + "ValueUnitType", + trace_simple::, + ), + ( + "VbaDirectoryValue", + trace_simple::, + ), + ( + "VerticalAlignment", + trace_simple::, + ), + ( + "ViewportRenderMode", + trace_simple::, + ), + ( + "VisualStylePropertyValue", + trace_simple::, + ), + ( + "ViewRepSketchGeometry", + trace_simple::, + ), + ( + "WipeoutClipMode", + trace_simple::, + ), + ( + "WipeoutClipType", + trace_simple::, + ), + ("WireType", trace_simple::), + ]; + for (name, trace) in enums { + trace(&mut tracer); + eprintln!("[ocs_plugin_api build] traced enum variants: {name}"); + } + let traced = tracer .registry() .expect("type registry tracing failed; see stderr for individual errors"); - let registry = map_to_custom_schema(&traced); + let mut registry = map_to_custom_schema(&traced); + let mut section_style_tracer = Tracer::new(TracerConfig::default()); + section_style_tracer + .trace_simple_type::() + .expect("section view style tracing failed"); + let section_style_registry = section_style_tracer + .registry() + .expect("section view style registry failed"); + let section_style = section_style_registry + .get("SectionViewStyle") + .expect("section view style registry entry"); + registry.types.insert( + TypeId::new("EntitySectionViewStyle"), + map_container("EntitySectionViewStyle", section_style), + ); let json = serde_json::to_string_pretty(®istry).unwrap(); fs::write(out_dir.join("type_registry.json"), json).unwrap(); } @@ -114,10 +501,22 @@ where } } +fn trace_simple(tracer: &mut Tracer) +where + T: DeserializeOwned, +{ + if let Err(error) = tracer.trace_simple_type::() { + panic!( + "type registry enum tracing failed for {}: {error}", + std::any::type_name::() + ); + } +} + fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { // serde-reflection needs at least one sample value per enum variant in // order to reconstruct the full schema. Provide samples for the enums that - // appear inside the allow-list types below. + // need concrete serialized values. let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByLayer); let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByBlock); let _ = tracer.trace_value(samples, &acadrust::LineWeight::Default); diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index 0517d4dc5..2b5dfc794 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -1,17 +1,15 @@ //! Embedded type registry generated at build time. //! -//! The JSON embedded here is produced by tracing a curated allow-list of -//! `acadrust` types with `serde-reflection` and mapping the result into a -//! stable, language-binding-friendly schema defined in +//! The JSON embedded here is produced by tracing every editor record root and +//! its dependent `acadrust` types with `serde-reflection`, then mapping the +//! result into a stable, language-binding-friendly schema defined in //! [`crate::type_registry_types`]. The registry is embedded via //! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the //! `host` feature is not required to read it. //! -//! The allow-list is intentionally narrow: it excludes generic containers and -//! internal enums such as `EntityType` in favor of concrete serializable structs -//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling -//! can depend on this schema without pulling the full `acadrust` dependency -//! tree. +//! The registry covers the complete entity, object, symbol-table, header, and +//! document-record roots used by the editor. Language bindings and tooling can +//! inspect every variant without pulling the full `acadrust` dependency tree. pub use crate::type_registry_types::*; @@ -121,13 +119,45 @@ mod tests { } #[test] - fn embedded_type_registry_does_not_contain_entity_type() { + fn embedded_type_registry_contains_every_entity_and_object_variant() { let registry: TypeRegistry = serde_json::from_str(get_embedded_type_registry_json()) .expect("valid TypeRegistry JSON"); - assert!( - !registry.types.contains_key(&TypeId::new("EntityType")), - "EntityType should not be in the allow-list registry" - ); + let entities = registry + .types + .get(&TypeId::new("EntityType")) + .expect("EntityType in complete registry"); + let objects = registry + .types + .get(&TypeId::new("ObjectType")) + .expect("ObjectType in complete registry"); + assert_eq!(entities.variants.len(), 48); + assert_eq!(objects.variants.len(), 36); + for root in [ + "HeaderVariables", + "SummaryInfo", + "LineType", + "TextStyle", + "BlockRecord", + "DimStyle", + "AppId", + "View", + "VPort", + "Ucs", + "VxTableRecord", + "DxfClass", + "BlockVisibilityParameter", + "FieldDef", + "DgnLsDefinition", + "DgnLsComponent", + "NotificationCollection", + "Preview", + "EntitySectionViewStyle", + ] { + assert!( + registry.types.contains_key(&TypeId::new(root)), + "missing {root}" + ); + } } #[test] diff --git a/docs/automation/README.md b/docs/automation/README.md index a3646a89d..1dcf4a90e 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -33,6 +33,21 @@ The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `oc Call `ocs_read` with `op: "capabilities"` before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The `records.collections` list gives every available database collection, its record count, and whether it can be edited. +Call `ocs_read` with `op: "record_schema"` before editing an unfamiliar record. With no parameters it lists the complete generated type registry. A collection returns the record types accepted by that collection even when the current drawing has no instance of a type. Supplying both `collection` and `type` returns the type's complete dependency graph, flattened property paths, JSON types, optional and sequence markers, enum variants, integer bounds, unambiguous unit annotations, identity fields, and write rules: + +```json +{ + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "record_schema", + "parameters": { + "collection": "entities", + "type": "Insert" + } +} +``` + +Sequence fields include an `item_path_template` such as `/attributes/{index}`. Replace `{index}` with an index returned by `records` before using the path in `set_properties`. When a matching record exists, `runtime_schema` adds its exact JSON shape and bounded scalar examples. The embedded registry is generated from all 48 entity variants, all 36 non-graphical object variants, symbol-table records, header data, summary data, and the remaining stored record roots at build time. + `op: "records"` returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting `collection` returns the same collection manifest; use `collection: "all"` for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and `properties`. Entity records also include their display and file record type names. Property filters and projections use RFC 6901 JSON Pointer paths relative to `properties`. Multiple `where` filters are combined with AND. Supported operators are `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `starts_with`, `ends_with`, `in`, `exists`, and `not_exists`: diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index d86a83887..000911c6b 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -56,6 +56,7 @@ def legacy(server: Path) -> None: assert execute_request["properties"]["steps"]["maxItems"] == 64 assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" assert "set_properties" in execute_request["properties"]["op"]["enum"] + assert "record_schema" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"] assert {"collection", "where", "paths"} <= set(read_parameters) assert execute_request["properties"]["kind"]["enum"] == [ diff --git a/src/app/automation.rs b/src/app/automation.rs index 3b03c756e..965a2f821 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -367,6 +367,7 @@ impl OpenCADStudio { "entities" => self.entity_summary(), "query" => self.entity_query(&req), "records" => self.record_query(&req), + "record_schema" => self.record_schema(&req), "capabilities" => self.record_capabilities(), "layers" => { let i = self.active_tab; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 4fd6ef76e..d6b67e7f2 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -361,7 +361,7 @@ impl OpenCADStudio { "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, "operation":self.control.pending.as_ref().map(|p| &p.id), - "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -378,6 +378,7 @@ impl OpenCADStudio { | "measure" | "query" | "records" + | "record_schema" | "capabilities" | "entities" | "layers" diff --git a/src/app/record_api.rs b/src/app/record_api.rs index c3f77fbac..843c04802 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -2,6 +2,8 @@ use acadrust::tables::{Table, TableEntry}; use iced::Task; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{Map, Value, json}; +use std::collections::VecDeque; +use std::sync::OnceLock; use super::{Message, OpenCADStudio}; @@ -36,6 +38,135 @@ const COLLECTIONS: &[(&str, bool)] = &[ ("document", false), ]; +fn collection_root_type(collection: &str) -> Option<&'static str> { + Some(match collection { + "entities" => "EntityType", + "objects" => "ObjectType", + "layers" => "Layer", + "line_types" => "LineType", + "text_styles" => "TextStyle", + "block_records" => "BlockRecord", + "dim_styles" => "DimStyle", + "app_ids" => "AppId", + "views" => "View", + "vports" => "VPort", + "ucss" => "Ucs", + "vx_table" => "VxTableRecord", + "header" => "HeaderVariables", + "summary_info" => "SummaryInfo", + "classes" => "DxfClass", + "block_visibility" => "BlockVisibilityParameter", + "context_scales" + | "block_representations" + | "vx_control_entries" + | "section_view_representations" + | "view_rep_references" => "Handle", + "fields" => "FieldDef", + "dgn_line_style_definitions" => "DgnLsDefinition", + "dgn_line_style_components" => "DgnLsComponent", + "notifications" => "NotificationCollection", + "preview" => "Preview", + "section_view_style" => "EntitySectionViewStyle", + // This aggregate is assembled by OCS and has a live inferred schema. + "document" => "Value", + _ => return None, + }) +} + +fn collection_is_mutable(collection: &str) -> bool { + COLLECTIONS + .iter() + .find(|(name, _)| *name == collection) + .is_some_and(|(_, mutable)| *mutable) +} + +fn collection_record_name(collection: &str) -> Option<&'static str> { + Some(match collection { + "classes" => "Class", + "fields" => "Field", + "dgn_line_style_definitions" => "LineStyleDefinition", + "dgn_line_style_components" => "LineStyleComponent", + "section_view_style" => "SectionViewStyle", + "view_rep_references" => "HandleReferences", + "notifications" => "Notifications", + "document" => "Document", + _ => collection_root_type(collection)?, + }) +} + +fn identity_paths(collection: &str) -> &'static [&'static str] { + match collection { + "entities" => &["/common/handle", "/common/owner_handle"], + "objects" => &[ + "/handle", + "/owner", + "/common/handle", + "/common/owner_handle", + ], + "header" => &["/handle_seed"], + "summary_info" => &[], + "layers" | "line_types" | "text_styles" | "block_records" | "dim_styles" | "app_ids" + | "views" | "vports" | "ucss" | "vx_table" => &["/handle", "/name"], + _ => &[], + } +} + +fn path_changes_identity(collection: &str, path: &str) -> bool { + path_is_identity_field(collection, path) + || identity_paths(collection).iter().any(|identity| { + path == *identity + || identity + .strip_prefix(path) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +fn path_is_identity_field(collection: &str, path: &str) -> bool { + match collection { + "entities" => path.ends_with("/common/handle") || path.ends_with("/common/owner_handle"), + "objects" => { + matches!(path, "/handle" | "/owner") + || path.ends_with("/common/handle") + || path.ends_with("/common/owner_handle") + } + _ => identity_paths(collection).contains(&path), + } +} + +fn collect_identity_values(value: &Value, collection: &str, path: &str, output: &mut Vec) { + if path_is_identity_field(collection, path) { + output.push(json!({"path":path,"value":value})); + } + match value { + Value::Object(object) => { + for (name, value) in object { + collect_identity_values( + value, + collection, + &format!("{path}/{}", json_pointer_name(name)), + output, + ); + } + } + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + collect_identity_values(value, collection, &format!("{path}/{index}"), output); + } + } + _ => {} + } +} + +fn registry_types() -> &'static Map { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + serde_json::from_str(ocs_plugin_api::get_embedded_type_registry_json()) + .expect("embedded type registry") + })["types"] + .as_object() + .expect("type registry object") +} + fn failure(code: &str, message: impl Into) -> Value { json!({"ok":false,"status":"failed","code":code,"error":message.into()}) } @@ -504,6 +635,467 @@ fn project_paths(mut record: Value, paths: Option<&Vec>) -> Result Option { + let types = registry_types(); + types + .get_key_value(requested) + .or_else(|| { + types + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(requested)) + }) + .map(|(name, _)| name.clone()) +} + +fn collect_type_references(value: &Value, pending: &mut VecDeque) { + match value { + Value::Object(object) => { + for (key, value) in object { + if key == "type_id" { + if let Some(type_id) = value.as_str() { + for token in type_id.split(|character: char| { + !character.is_ascii_alphanumeric() && character != '_' + }) { + if registry_types().contains_key(token) { + pending.push_back(token.to_string()); + } + } + } + } + collect_type_references(value, pending); + } + } + Value::Array(values) => { + for value in values { + collect_type_references(value, pending); + } + } + _ => {} + } +} + +fn referenced_definitions(root: &str) -> Map { + let mut definitions = Map::new(); + let mut pending = VecDeque::from([root.to_string()]); + while let Some(name) = pending.pop_front() { + if definitions.contains_key(&name) { + continue; + } + let Some(info) = registry_types().get(&name) else { + continue; + }; + collect_type_references(info, &mut pending); + definitions.insert(name, info.clone()); + } + definitions +} + +fn json_pointer_name(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +fn field_unit(name: &str, type_id: &str) -> Option<&'static str> { + let numeric = matches!( + type_id, + "f32" + | "f64" + | "i8" + | "i16" + | "i32" + | "i64" + | "u8" + | "u16" + | "u32" + | "u64" + | "Vector2" + | "Vector3" + ); + if !numeric { + return None; + } + let name = name.to_ascii_lowercase(); + if name.contains("angle") || name == "rotation" || name.contains("twist") { + return Some("radians"); + } + if name.contains("scale") || name.contains("ratio") || name.contains("factor") { + return Some("unitless"); + } + if [ + "point", + "position", + "origin", + "center", + "location", + "elevation", + "height", + "width", + "length", + "radius", + "diameter", + "offset", + "thickness", + "distance", + "size", + "spacing", + ] + .iter() + .any(|part| name.contains(part)) + { + return Some("drawing_units"); + } + None +} + +fn type_constraints(type_id: &str) -> Option { + let integer_bounds = match type_id { + "i8" => Some(json!({"minimum":i8::MIN,"maximum":i8::MAX})), + "i16" => Some(json!({"minimum":i16::MIN,"maximum":i16::MAX})), + "i32" => Some(json!({"minimum":i32::MIN,"maximum":i32::MAX})), + "i64" => Some(json!({"minimum":i64::MIN,"maximum":i64::MAX})), + "u8" => Some(json!({"minimum":0,"maximum":u8::MAX})), + "u16" => Some(json!({"minimum":0,"maximum":u16::MAX})), + "u32" => Some(json!({"minimum":0,"maximum":u32::MAX})), + "u64" | "Handle" => Some(json!({"minimum":0,"maximum":u64::MAX})), + _ => None, + }; + if integer_bounds.is_some() { + return integer_bounds; + } + let info = registry_types().get(type_id)?; + if info["kind"] != "Enum" { + return None; + } + Some(json!({ + "allowed_variants":info["variants"].as_array().into_iter().flatten() + .filter_map(|variant|variant["name"].as_str()).collect::>() + })) +} + +fn append_schema_fields( + owner_type: &str, + type_fields: &[Value], + prefix: &str, + collection: Option<&str>, + depth: usize, + stack: &mut Vec, + fields: &mut Vec, +) { + for field in type_fields { + let Some(name) = field["name"].as_str() else { + continue; + }; + let type_id = field["type_id"].as_str().unwrap_or("Value"); + let path = format!("{prefix}/{}", json_pointer_name(name)); + let sequence = field["is_sequence"].as_bool().unwrap_or(false); + let writable = collection.is_some_and(collection_is_mutable) + && collection.is_none_or(|collection| !path_changes_identity(collection, &path)); + let mut description = json!({ + "path":path, + "name":name, + "description":format!("{} property on {owner_type}", name.replace('_', " ")), + "type":type_id, + "optional":field["optional"].as_bool().unwrap_or(false), + "sequence":sequence, + "writable":writable, + }); + let object = description.as_object_mut().expect("field description"); + if sequence { + object.insert( + "item_path_template".into(), + json!(format!("{path}/{{index}}")), + ); + } + if let Some(unit) = field_unit(name, type_id) { + object.insert("unit".into(), json!(unit)); + } + if let Some(constraints) = type_constraints(type_id) { + object.insert("constraints".into(), constraints); + } + fields.push(description); + + let nested_prefix = if sequence { + format!("{path}/{{index}}") + } else { + path + }; + if registry_types().contains_key(type_id) { + append_field_descriptions( + type_id, + &nested_prefix, + collection, + depth + 1, + stack, + fields, + ); + } + } +} + +fn append_field_descriptions( + type_name: &str, + prefix: &str, + collection: Option<&str>, + depth: usize, + stack: &mut Vec, + fields: &mut Vec, +) { + if depth > 12 || stack.iter().any(|name| name == type_name) { + return; + } + let Some(info) = registry_types().get(type_name) else { + return; + }; + stack.push(type_name.to_string()); + match info["kind"].as_str() { + Some("Struct") => append_schema_fields( + type_name, + info["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]), + prefix, + collection, + depth, + stack, + fields, + ), + Some("Enum") => append_enum_descriptions( + type_name, + info["variants"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]), + prefix, + collection, + depth, + stack, + fields, + ), + _ => {} + } + stack.pop(); +} + +fn append_enum_descriptions( + enum_type: &str, + variants: &[Value], + prefix: &str, + collection: Option<&str>, + depth: usize, + stack: &mut Vec, + fields: &mut Vec, +) { + for variant in variants { + let Some(variant_name) = variant["name"].as_str() else { + continue; + }; + let Some(variant_fields) = variant["fields"] + .as_array() + .filter(|fields| !fields.is_empty()) + else { + continue; + }; + let path = format!("{prefix}/{}", json_pointer_name(variant_name)); + let newtype = (variant_fields.len() == 1 && variant_fields[0]["name"] == "0") + .then(|| &variant_fields[0]); + let type_id = newtype + .and_then(|field| field["type_id"].as_str()) + .unwrap_or("struct_variant"); + let sequence = newtype + .and_then(|field| field["is_sequence"].as_bool()) + .unwrap_or(false); + let writable = collection.is_some_and(collection_is_mutable) + && collection.is_none_or(|collection| !path_changes_identity(collection, &path)); + let mut description = json!({ + "path":path, + "name":variant_name, + "description":format!("{variant_name} variant on {enum_type}"), + "type":type_id, + "enum":enum_type, + "variant":variant_name, + "optional":false, + "sequence":sequence, + "writable":writable, + }); + if sequence { + description + .as_object_mut() + .expect("variant description") + .insert( + "item_path_template".into(), + json!(format!("{path}/{{index}}")), + ); + } + if let Some(unit) = field_unit(variant_name, type_id) { + description + .as_object_mut() + .expect("variant description") + .insert("unit".into(), json!(unit)); + } + if let Some(constraints) = type_constraints(type_id) { + description + .as_object_mut() + .expect("variant description") + .insert("constraints".into(), constraints); + } + fields.push(description); + + if newtype.is_some() { + if registry_types().contains_key(type_id) { + let nested_prefix = if sequence { + format!("{path}/{{index}}") + } else { + path + }; + append_field_descriptions( + type_id, + &nested_prefix, + collection, + depth + 1, + stack, + fields, + ); + } + } else { + append_schema_fields( + variant_name, + variant_fields, + &path, + collection, + depth + 1, + stack, + fields, + ); + } + } +} + +fn append_variant_field_descriptions( + enum_type: &str, + variant_name: &str, + collection: Option<&str>, + fields: &mut Vec, +) { + let Some(variant_fields) = registry_types() + .get(enum_type) + .and_then(|info| info["variants"].as_array()) + .and_then(|variants| { + variants.iter().find(|variant| { + variant["name"] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(variant_name)) + }) + }) + .and_then(|variant| variant["fields"].as_array()) + else { + return; + }; + let mut stack = vec![enum_type.to_string()]; + append_schema_fields( + variant_name, + variant_fields, + "", + collection, + 0, + &mut stack, + fields, + ); +} + +fn runtime_value_schema(value: &Value, collection: &str, path: &str) -> Value { + let mut schema = match value { + Value::Null => json!({"type":"null"}), + Value::Bool(value) => json!({"type":"boolean","example":value}), + Value::Number(value) if value.is_i64() || value.is_u64() => { + json!({"type":"integer","example":value}) + } + Value::Number(value) => json!({"type":"number","example":value}), + Value::String(value) => { + let example: String = value.chars().take(160).collect(); + json!({"type":"string","example":example}) + } + Value::Array(values) => { + let item = values + .first() + .map(|value| runtime_value_schema(value, collection, &format!("{path}/0"))) + .unwrap_or_else(|| json!({})); + json!({"type":"array","items":item}) + } + Value::Object(values) => { + let properties = values + .iter() + .map(|(name, value)| { + let child_path = format!("{path}/{}", json_pointer_name(name)); + ( + name.clone(), + runtime_value_schema(value, collection, &child_path), + ) + }) + .collect::>(); + json!({ + "type":"object", + "properties":properties, + "required":values.keys().collect::>(), + "additionalProperties":false, + }) + } + }; + if !path.is_empty() { + let object = schema.as_object_mut().expect("runtime schema"); + object.insert( + "readOnly".into(), + json!(!collection_is_mutable(collection) || path_changes_identity(collection, path)), + ); + let name = path.rsplit('/').next().unwrap_or(""); + let scalar_type = if value.is_number() { "f64" } else { "Value" }; + if let Some(unit) = field_unit(name, scalar_type) { + object.insert("x-unit".into(), json!(unit)); + } + } + schema +} + +fn editing_description(collection: Option<&str>) -> Value { + let mutable = collection.is_some_and(collection_is_mutable); + json!({ + "read_operation":"records", + "write_operation":mutable.then_some("set_properties"), + "selector":match collection { + Some("entities" | "objects") => "handle", + Some("header" | "summary_info") => "singleton", + Some(_) if mutable => "handle or name", + Some(_) => "read-only derived record", + None => "collection plus handle or name", + }, + "path":"RFC 6901 JSON Pointer relative to record.properties", + "sequence_path":"Replace {index} in item_path_template with a current zero-based array index", + "identity_paths":collection.map(identity_paths).unwrap_or(&[]), + "atomic":true, + "compare_and_set":"Add expected to an update to reject stale field values", + "validation":["document revision","request id","JSON type","enum variant","integer bounds","record identity","layer lock"], + }) +} + +fn collection_record_types(collection: &str) -> Vec { + let root = collection_root_type(collection).unwrap_or("Value"); + if matches!(collection, "entities" | "objects") { + return registry_types()[root]["variants"] + .as_array() + .into_iter() + .flatten() + .map(|variant| { + let fields = variant["fields"].as_array(); + let schema_type = fields + .filter(|fields| fields.len() == 1 && fields[0]["name"] == "0") + .and_then(|fields| fields[0]["type_id"].as_str()) + .unwrap_or(root); + json!({"type":variant["name"],"schema_type":schema_type}) + }) + .collect(); + } + vec![json!({ + "type":collection_record_name(collection).unwrap_or(root), + "schema_type":root, + })] +} + fn requested_handle(request: &Value) -> Result { request["handle"] .as_str() @@ -523,6 +1115,8 @@ fn apply_updates( if updates.is_empty() { return Err("set_properties requires a non-empty updates array".into()); } + let mut original_identity = Vec::new(); + collect_identity_values(properties, collection, "", &mut original_identity); let mut paths = Vec::with_capacity(updates.len()); for update in updates { let path = update["path"] @@ -531,14 +1125,7 @@ fn apply_updates( if path.is_empty() || !path.starts_with('/') { return Err("updates require a non-empty JSON Pointer path".into()); } - let identity = match collection { - "entities" => matches!(path, "/common/handle" | "/common/owner_handle"), - "objects" => matches!(path, "/handle" | "/common/handle"), - "header" => path == "/handle_seed", - "summary_info" => false, - _ => matches!(path, "/handle" | "/name"), - }; - if identity { + if path_changes_identity(collection, path) { return Err(format!("{path} is read-only identity data")); } let value = update @@ -556,6 +1143,11 @@ fn apply_updates( *target = value; paths.push(path.to_string()); } + let mut edited_identity = Vec::new(); + collect_identity_values(properties, collection, "", &mut edited_identity); + if edited_identity != original_identity { + return Err("record identity is read-only".into()); + } Ok(paths) } @@ -611,11 +1203,13 @@ impl OpenCADStudio { "records":{ "read":"records", "write":"set_properties", + "describe":"record_schema", "path":"RFC 6901 JSON Pointer relative to properties", "filters":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"], "collections":COLLECTIONS.iter().map(|(name, mutable)|json!({ "name":name, "mutable":mutable, + "root_type":collection_root_type(name), "count":collection_count(document, name), })).collect::>() }, @@ -623,6 +1217,151 @@ impl OpenCADStudio { }) } + pub(super) fn record_schema(&self, request: &Value) -> Value { + let collection = request["collection"].as_str(); + if collection.is_some_and(|name| collection_root_type(name).is_none()) { + return failure( + "unknown_collection", + format!("unknown record collection: {}", collection.unwrap()), + ); + } + let collections = COLLECTIONS + .iter() + .map(|(name, mutable)| { + json!({ + "name":name, + "mutable":mutable, + "root_type":collection_root_type(name), + "identity_paths":identity_paths(name), + }) + }) + .collect::>(); + + let Some(requested_type) = request["type"].as_str() else { + let mut record_types = if let Some(collection) = collection { + collection_record_types(collection) + } else { + registry_types() + .iter() + .map(|(name, info)| json!({"type":name,"schema_type":name,"kind":info["kind"]})) + .collect() + }; + if let Some(search) = request["search"].as_str().filter(|value| !value.is_empty()) { + let search = search.to_ascii_lowercase(); + record_types.retain(|entry| { + entry["type"] + .as_str() + .is_some_and(|name| name.to_ascii_lowercase().contains(&search)) + }); + } + let count = record_types.len(); + let offset = request["offset"].as_u64().unwrap_or(0) as usize; + let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; + let record_types = record_types + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + return json!({ + "ok":true, + "schema_version":1, + "collection":collection, + "root_type":collection.and_then(collection_root_type), + "collections":collections, + "count":count, + "returned":record_types.len(), + "next_offset":(offset + record_types.len() < count).then_some(offset + record_types.len()), + "record_types":record_types, + "editing":editing_description(collection), + "next":"Call record_schema again with type set to a record type or schema_type returned here." + }); + }; + + let root_type = collection + .and_then(|collection| { + collection_record_types(collection) + .into_iter() + .find(|entry| { + ["type", "schema_type"].iter().any(|key| { + entry[*key] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)) + }) + }) + .and_then(|entry| entry["schema_type"].as_str().map(str::to_string)) + }) + .or_else(|| resolved_type_name(requested_type)) + .or_else(|| { + (requested_type == "Value" + || collection.and_then(collection_root_type) == Some("Value")) + .then(|| "Value".to_string()) + }); + let Some(root_type) = root_type else { + return failure( + "unknown_record_type", + format!("unknown record type: {requested_type}"), + ); + }; + let mut fields = Vec::new(); + let direct_collection_variant = + collection + .and_then(collection_root_type) + .is_some_and(|collection_root| { + collection_root == root_type && !requested_type.eq_ignore_ascii_case(&root_type) + }) + && registry_types() + .get(&root_type) + .is_some_and(|info| info["kind"] == "Enum"); + if direct_collection_variant { + append_variant_field_descriptions(&root_type, requested_type, collection, &mut fields); + } else { + append_field_descriptions(&root_type, "", collection, 0, &mut Vec::new(), &mut fields); + } + let definitions = referenced_definitions(&root_type); + let runtime_schema = collection.and_then(|collection| { + collection_records(&self.tabs[self.active_tab].scene.document, collection) + .ok()? + .into_iter() + .find(|record| { + let type_matches = record["type"] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)) + || collection_root_type(collection) + .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)); + type_matches + && request["handle"].as_str().is_none_or(|handle| { + record["handle"].as_str().is_some_and(|actual| { + actual.eq_ignore_ascii_case(without_hex_prefix(handle)) + }) + }) + && request["name"].as_str().is_none_or(|name| { + record["name"] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(name)) + }) + }) + .map(|record| runtime_value_schema(&record["properties"], collection, "")) + }); + json!({ + "ok":true, + "schema_version":1, + "collection":collection, + "record_type":requested_type, + "schema_type":root_type, + "schema":registry_types().get(&root_type), + "definitions":definitions, + "fields":fields, + "runtime_schema":runtime_schema, + "editing":editing_description(collection), + "notes":[ + "Enum constraints list every accepted serialized variant.", + "Integer constraints reflect the complete serialized range.", + "Unit annotations are supplied only where the serialized field name and type are unambiguous.", + "runtime_schema describes an existing matching record when one is available." + ] + }) + } + pub(super) fn record_query(&self, request: &Value) -> Value { let document = &self.tabs[self.active_tab].scene.document; let Some(collection) = request["collection"].as_str() else { @@ -990,6 +1729,104 @@ mod tests { assert!(app.tabs[i].scene.document.summary_info.author.is_empty()); } + #[test] + fn record_schema_describes_absent_types_fields_enums_units_and_write_rules() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + let catalog = app.record_schema(&json!({"collection":"entities"})); + assert_eq!(catalog["ok"], true, "{catalog}"); + assert_eq!(catalog["root_type"], "EntityType"); + assert_eq!(catalog["count"], 48); + assert!(catalog["record_types"] + .as_array() + .unwrap() + .iter() + .any(|record| record["type"] == "Insert" && record["schema_type"] == "Insert")); + + let schema = app.record_schema(&json!({ + "collection":"entities", + "type":"Insert" + })); + assert_eq!(schema["ok"], true, "{schema}"); + assert!(schema["definitions"].get("Insert").is_some()); + assert!(schema["definitions"].get("AttributeEntity").is_some()); + assert!(schema["runtime_schema"].is_null()); + let fields = schema["fields"].as_array().unwrap(); + let field = |path: &str| { + fields + .iter() + .find(|field| field["path"] == path) + .unwrap_or_else(|| panic!("missing schema field {path}")) + }; + assert_eq!(field("/common/handle")["writable"], false); + assert_eq!(field("/insert_point")["unit"], "drawing_units"); + assert_eq!(field("/rotation")["unit"], "radians"); + assert_eq!( + field("/attributes")["item_path_template"], + "/attributes/{index}" + ); + assert_eq!(field("/attributes/{index}/value")["writable"], true); + + let text = app.record_schema(&json!({"collection":"entities","type":"Text"})); + let alignment = text["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["path"] == "/horizontal_alignment") + .expect("horizontal alignment schema"); + assert!(alignment["constraints"]["allowed_variants"] + .as_array() + .is_some_and(|variants| variants.len() >= 3)); + + let unknown = app.record_schema(&json!({"collection":"objects","type":"Unknown"})); + assert_eq!(unknown["schema_type"], "ObjectType"); + let fields = unknown["fields"].as_array().unwrap(); + let field = |path: &str| { + fields + .iter() + .find(|field| field["path"] == path) + .unwrap_or_else(|| panic!("missing enum variant field {path}")) + }; + assert_eq!(field("/type_name")["writable"], true); + assert_eq!(field("/handle")["writable"], false); + assert_eq!(field("/owner")["writable"], false); + assert_eq!(field("/raw_dwg_handle_bits")["writable"], true); + + let dimension = app.record_schema(&json!({"collection":"entities","type":"Dimension"})); + let fields = dimension["fields"].as_array().unwrap(); + assert!(fields.iter().any(|field| field["path"] == "/Linear")); + assert!(fields.iter().any( + |field| field["path"] == "/Linear/base/common/handle" && field["writable"] == false + )); + assert!(fields.iter().any(|field| field["path"] == "/Radius")); + + let section_style = app + .record_schema(&json!({"collection":"section_view_style","type":"SectionViewStyle"})); + assert_eq!(section_style["schema_type"], "EntitySectionViewStyle"); + assert!(section_style["fields"] + .as_array() + .is_some_and(|fields| fields + .iter() + .any(|field| field["path"] == "/arrow_size" && field["unit"] == "drawing_units"))); + + let class = app.record_schema(&json!({"collection":"classes","type":"Class"})); + assert_eq!(class["schema_type"], "DxfClass"); + assert!(class["fields"] + .as_array() + .is_some_and(|fields| fields.iter().any(|field| field["path"] == "/dxf_name"))); + + for (collection, root) in [("entities", "EntityType"), ("objects", "ObjectType")] { + let complete = app.record_schema(&json!({"collection":collection,"type":root})); + assert_eq!(complete["ok"], true, "{root}: {complete}"); + assert!(complete["fields"] + .as_array() + .is_some_and(|fields| !fields.is_empty())); + let bytes = serde_json::to_vec(&complete).unwrap().len(); + assert!(bytes < 8 * 1024 * 1024, "{root} schema is {bytes} bytes"); + } + } + #[test] fn record_updates_reject_type_identity_and_compare_failures() { let mut app = OpenCADStudio::new_for_test(); @@ -1016,6 +1853,17 @@ mod tests { ); assert_eq!(identity["code"], "invalid_update", "{identity}"); + let identity_parent = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/common","value":{}}]}), + "identity-parent", + ); + assert_eq!( + identity_parent["code"], "invalid_update", + "{identity_parent}" + ); + let compare = execute( &mut app, json!({"op":"set_properties","collection":"entities","handle":handle, diff --git a/src/mcp.rs b/src/mcp.rs index 3db397aae..71866f815 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,12 +22,13 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", "query", "records", + "record_schema", "capabilities", "entities", "layers", @@ -986,8 +987,8 @@ fn tool_definitions() -> Value { }, { "name":"ocs_read", - "description":"Discover capabilities or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all, or omit to discover collections."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, @@ -1356,6 +1357,7 @@ mod tests { ); assert!(READ_OPS.contains(&"capabilities")); assert!(READ_OPS.contains(&"records")); + assert!(READ_OPS.contains(&"record_schema")); assert!(EXECUTE_OPS.contains(&"set_properties")); assert_eq!( tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"]